236bb3270e6716fe77360714ab47ca1da20ce08b
150 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
236bb3270e |
Merge: t-paliad-164 — project our_side + Determinator perspective predefine
Three slices on mai/minkowski/project-level-our-side: |
||
|
|
188d8ec9ba |
feat(projects): add projects.our_side column + service plumbing (t-paliad-164 slice 1)
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.
|
||
|
|
ae1cba4e24 |
feat(paliadin/primer): t-paliad-161 Slice G — tmux crash-recovery primer
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).
|
||
|
|
1782dfa910 |
feat(paliadin/cross-surface-sync): t-paliad-161 Slice F — DB-driven history hydrate
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.
|
||
|
|
0b47343aa3 |
fix(projects-cards): start_at not starts_at — cards-preview appointments query
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. |
||
|
|
d2790a0461 |
feat(paliadin): reconcile late responses via janitor + chat polling
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.
|
||
|
|
97d49898b7 |
fix(paliadin): 200ms settle delay between paste and Enter so submit registers
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.) |
||
|
|
6fcf34a3e3 |
feat(determinator/slice-3c): perspective chip + party-tagged cascade narrowing
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.
|
||
|
|
4ecea7a4bb |
feat(paliadin/agent-glyph): t-paliad-161 Slice E — ✨ alongside 👀
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. |
||
|
|
a3052eb085 |
feat(paliadin/suggest): t-paliad-161 Slice D — agent-suggested write path
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. |
||
|
|
0d1a7ba886 |
feat(paliadin/context): t-paliad-161 Slice B — structured page-context payload
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.
|
||
|
|
e2907db760 |
Merge: t-paliad-157 dogfood batch — eye glyph 👀, optional deadlines, Verfahrensablauf collapse
Three commits from mai/feynman/fristenrechner: - |
||
|
|
2d6ea3ee33 |
feat(deadline-rules/is-optional): conditional rules opt-in via save modal
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).
|
||
|
|
c06be27cce |
Merge: t-paliad-157 — Fristenrechner items 3 + 1 stopgap (feynman, ac15911 moves the inbox-channel chip from /tools/fristenrechner page-top into the B1 cascade panel + drops the Pathway A picker filter — persistence + URL override + B1 cascade narrowing + B2 fine-chip sync still apply, just no longer page-level prefilter; ef78f59 Item-1 stopgap for chained court-set rules — RoP.151-style rules whose trigger is itself a court-set event now render 'unbestimmt' instead of 'wird vom Gericht bestimmt' via a new IsCourtSetIndirect flag, direct court events keep the original label. Items 2 + 4 from m's 2026-05-08 17:41 batch still pending.)
|
||
|
|
ef78f59d25 |
feat(fristenrechner): "unbestimmt" for chained court-set rules (m's R.151 case)
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).
|
||
|
|
9350cd0e87 | Merge remote-tracking branch 'origin/main' into mai/shannon/approval-rework | ||
|
|
aec6cf6104 |
refactor(approvals/t-paliad-160 slice3 / M2): drop required_role column
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.
|
||
|
|
073af975f7 |
feat(approvals/t-paliad-160 slice2): admin UI flip + badge + withdraw + inbox visibility hardening
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.
|
||
|
|
3a41aa9209 |
feat(approvals/t-paliad-160 slice1+2): split policy + 409 handler
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)
|
||
|
|
6ef14ddc39 |
feat(fristenrechner/inbox-chip): wire inbox into B1 cascade narrowing
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). |
||
|
|
06bd276a9c |
feat(users/forum-pref): persisted Fristenrechner inbox-channel column
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. |
||
|
|
7daa70aaad |
feat(paliadin/projects-cards): markdown rendering + grid compactness + overdue-pending in NextEvents
Three issues from m's dogfood (2026-05-08 15:02–15:14): ## A. /projects-cards on desktop overflowed the right column .projects-cards-grid.is-grid-2 used grid-template-columns: repeat(2, 1fr) which is shorthand for repeat(2, minmax(auto, 1fr)). 'auto' resolves to max-content so any card with content wider than the track expands the track and pushes the right column past the parent's right edge. Switched is-grid-2/3/4 to repeat(N, minmax(0, 1fr)) which clamps the floor to zero — overflow now wraps/clips inside the card instead of blowing out the layout. Bonus: the auto-fill default also got the min(320px, 100%) treatment so narrow viewports collapse the floor and spare us horizontal scroll on mobile (mirrors t-paliad-155's earlier views-cards fix). ## B. "Nächste Termine" empty while "5 offen" showed CardsPreview's deadline source filtered WHERE f.status = 'pending' AND f.due_date >= today::date. m's 5 pending deadlines are all in the past — overdue — so they were excluded from NextEvents while still counted in the "X offen" badge. Dropped the >= today predicate. Now any pending deadline lands in NextEvents, sorted ASC by due_date, so most-overdue surfaces first (which matches m's mental model: an overdue Frist is more urgent than tomorrow's, not less). Appointments keep the >= now filter (past appointments are history, not next). Cleaned up the args[] threading since deadlines no longer needs the temporal bound. ## C. Chat bubbles ignored Markdown formatting (## h2, **bold**, lists) renderResponseHTML only handled chip markers + the new (today) markdown-link / bare-URL passes; everything else fell through as raw text. "## Projekte" rendered with the literal hashes visible. Added renderBlocks() — a small block-level parser that turns: - → <h2>H</h2> - → <h3>H</h3> - lines → <ul class=paliadin-list><li>...</li></ul> - → <hr> - blank-line-separated runs → <p>...<br>...</p> and inline emphasis passes that wrap **bold** in <strong> and *italic* in <em>. Block-level runs before the link passes so the regexes only operate inside a block; emphasis runs after links so a bold link works. Pipeline is still: escape → chip-stage → blocks → md-links → bare-urls → emphasis → unstage chips. ## D. (carrying over from earlier in this commit) /admin/paliadin monitor — show user + response preview + page origin + per-tool row counts |
||
|
|
05d14d5e5a |
feat(admin/paliadin): show user + response preview + page origin + per-tool row counts on the monitor
m's ask (2026-05-08 15:02): the Paliadin monitor should show which user
made each turn, and ideally log more than just timing/classifier.
Backend:
- PaliadinTurn gains UserEmail + UserDisplayName fields (json:omitempty
so user-facing API paths don't leak unrelated identity info; only
populated by the admin LIST query).
- ListRecentTurns LEFT JOINs paliad.users to surface email +
display_name on each row. The existing global_admin OR caller-owns
visibility predicate on the WHERE clause stays unchanged.
Frontend (admin-paliadin):
- Recent-turns table grows from 5 → 8 columns:
Zeit · Nutzer · Art · Anfrage · Antwort · Tools · Seite · Dauer
- Nutzer cell shows display_name (fallback email, fallback first 8 of
user_id), with the full email in the title attribute on hover.
- Antwort cell renders the first 80 chars of the response with the full
cleanBody available on hover. Useful for spot-checking what Paliadin
actually wrote without clicking through every turn.
- Tools cell now pairs each tool name with its rows_seen count
("list_my_projects (11), search_my_deadlines (18)") so the data
density is legible at a glance.
- Seite cell exposes page_origin (where in Paliad m kicked off the
turn) — was already audited but never surfaced.
- DE/EN i18n keys added for the four new column headers.
|
||
|
|
be2150c17d |
fix(paliadin): used_tools NOT NULL violation + frontend response truncation
Two bugs surfaced in m's dogfood of t-paliad-155 (2026-05-08 13:55).
## A. used_tools NOT NULL constraint violation on casual turns
paliad.paliadin_turns.used_tools is text[] NOT NULL DEFAULT '{}'. parseTrailer
leaves trailerMeta.UsedTools as nil when Claude omits the trailer ("Heyhey!")
or sends an empty list. completeTurn passed pq.StringArray(nil) which the pq
driver writes as NULL — UPDATE failed with constraint 23502 on every casual
chat turn, leaving the row half-finalized.
Fix: coerce UsedTools to a non-nil empty pq.StringArray before the UPDATE,
mirroring the existing rowsSeen pattern in the same function.
## B. Frontend rendered "## Proje" instead of the full 1408-byte response
m saw the first 8 characters of his Markdown response in the chat bubble,
plus the full meta row underneath. The DB row had the complete cleanBody
in 'response'. Truncation lived entirely in the browser.
Root cause: finishBubble read textNode.textContent at the moment of the
'end' event — but typewriter() animates the text 8 chars at a time, so
textContent was "## Proje" (one tick into 1408 bytes) when finishBubble
fired. renderResponseHTML(raw) baked in the partial state, then the
typewriter's next tick saw streaming='false' and ran 'node.textContent =
text' which overwrote the rendered HTML with the raw string — except in
this case the second tick never ran in time, leaving the partial render.
Fix:
1. Cache the full SSE-delivered text on placeholder.dataset.fullText at
content-event time. finishBubble prefers that over textContent.
2. Typewriter's abort branch no longer overwrites the node — finishBubble
already owns the final rendered HTML, so a delayed tick should just
return rather than blow away the rendered Markdown.
Both fixes verified locally: go build clean, bun build clean.
Refs t-paliad-155, m/paliad#12.
|
||
|
|
3e1f4eee4b |
fix(t-paliad-155): cold-start timeout headroom + ban DB fallbacks in skill
Shim's run-turn hard timeout: 60s → 120s (PALIADIN_TIMEOUT_S default). First turn after a fresh tmux session stacks claude boot + skill load + MCP discovery + first reasoning, which can blow past 60s before the response file lands. Aligned the surrounding timeouts so 120s is actually reachable: - callShim ctx (paliadin_remote.go): 70s → 130s (shim 120 + 10 SSH). - runPaliadinTurnAsync handler ctx: 120s → 150s (shim 120 + 10 SSH + 20 paliad-side overhead). SKILL.md hard rule #6 added: never fall back to psql / curl PostgREST / nix-shell — mcp__supabase__execute_sql is the only DB tool. If it's unavailable, write a short 'DB nicht erreichbar — bitte paliad neu deployen oder PALIADIN_REMOTE_CWD prüfen' response immediately with classifier_tag=meta. Saves the 60s-fallback-dance failure mode m hit on the cwd-misconfig turn. |
||
|
|
97a412498d |
feat(t-paliad-155): real Claude SKILL.md + per-user tmux session
Move Paliadin's persona + response protocol from a tmux-keystroke-injected
system prompt into a real Claude skill at ~/.claude/skills/paliadin/SKILL.md
(repo source: scripts/skills/paliadin/SKILL.md, install script:
scripts/install-paliadin-skill). Claude's skill router auto-matches the
[PALIADIN:<uuid>] envelope on every turn, so the protocol contract
survives /clear, fresh sessions, and pane restarts — root-cause fix for
the post-/clear stuck-spinner that triggered this task.
Per-user tmux session keying: each Paliad user gets a session named
<prefix>-<userid8> (first 8 hex chars of UUID). One persistent session
per user, conversation history accumulates per visit, ResetSession kills
the session entirely. Health-check cache becomes per-session.
Service-side simplifications:
- paliadin_prompt.go (paliadinSystemPrompt) deleted; trailer parser stays
in paliadin.go.
- paliadin_remote.go: ensureBootstrapped removed; healthGate takes a
session arg + caches per-key; ResetSession derives session from UserID
and shells out to 'reset <session>'.
- paliadin.go (LocalPaliadinService): per-user pane cache, ensurePane
takes UserID, no more in-process system-prompt send.
- Paliadin interface: ResetSession now takes UserID.
Shim refactor (scripts/paliadin-shim):
- All verbs accept the tmux session as their first positional arg.
- 'bootstrap' verb removed (skill replaces it).
- 'reset' kills the named session via tmux kill-session.
- Session name validated against [A-Za-z0-9_.-]{1,64}.
Env var rename: PALIADIN_TMUX_SESSION -> PALIADIN_SESSION_PREFIX (semantic
shift from literal session name to per-user prefix); CLAUDE.md updated.
Tests cover per-session health caching, session-name derivation,
ResetSession kill-session shape, and health-cache eviction on reset.
|
||
|
|
f820aa8316 | Merge: t-paliad-154 — approval-policy authoring UI (migration 062 paliad.approval_policies unit-defaults + 'none' sentinel + tree-walking resolver + 88 unit-default seed rows + paliad.policy_audit_log; ApprovalService rewire with resolver delegation + scope-split CRUD + audit emission; HTTP handlers admin APIs + form-hint endpoint + audit-log union; /admin/approval-policies admin page + admin-index card + form-time hints on deadline/appointment new pages + inbox empty-state nudge for admins; 13 m-locked design decisions honoured verbatim per docs/design-approval-policy-ui-2026-05-07.md §2) | ||
|
|
0f87d73b1b |
feat(t-paliad-154) commit 3/5: HTTP handlers — admin APIs + form-hint endpoint + audit-log union
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.
|
||
|
|
e6067c74db |
feat(t-paliad-154) commit 2/5: ApprovalService rewire — resolver delegation + scope-split CRUD + audit emission
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.
|
||
|
|
68c56ea920 |
test(t-paliad-151): paliadin_remote_test.go — RemotePaliadinService unit tests
14 tests covering: - NewRemotePaliadinService default values (SSHPort=22022, SSHUser="m") - NewRemotePaliadinService honours overrides - classifySSHError mapping (nil / explicit + wrapped ErrMRiverUnreachable / context.DeadlineExceeded / shim exit-124 timeout / Connection refused/timed out / Permission denied / unknown fallback) - healthGate caches OK results for 10 s - healthGate does NOT cache failures (every call re-probes) - healthGate rejects unexpected shim replies (returns wrap of ErrMRiverUnreachable) - healthGate cache expires after 10 s wall clock - ensureBootstrapped runs exactly once on success (idempotent) - ensureBootstrapped retries after failure, then caches the success - DisabledPaliadinService returns ErrPaliadinDisabled from RunTurn + ResetSession - compile-time Paliadin interface conformance for all three impls - callShim forwards args verbatim through the test hook - callShim error-wrapping path preserves stderr (so classifySSHError can pattern-match Permission denied / Connection refused etc.) All tests bypass exec via the callShimHook field — no real ssh, no real DB. RunTurn audit-row tests are out of scope (paliad has no sqlx mock; existing paliadin_test.go also stays on pure functions). Refs m/paliad#12 |
||
|
|
0c8a2f1a95 |
feat(t-paliad-151) RemotePaliadinService + main.go env-var routing
Phase B step 2: lands the Paliadin backend that talks to mRiver via ssh + paliadin-shim. Local backend untouched — selection happens in cmd/server/main.go based on PALIADIN_REMOTE_HOST. Files: - internal/services/paliadin_remote.go (new) — RemotePaliadinService + RemotePaliadinConfig, with five SSH knobs (Host/Port/User/KeyPath/ KnownHostsPath). RunTurn does insertTurnRow → healthGate → bootstrap → callShim run-turn → splitTrailer → completeTurn, mirroring the local path's audit-row contract. ResetSession sends shim 'reset'. callShim runs `ssh -F /dev/null -i <key> -p <port> -o … host -- verb args`; ControlMaster intentionally not enabled (design §6.8). - internal/services/paliadin_remote.go also adds DisabledPaliadinService (returns ErrPaliadinDisabled from RunTurn/ResetSession; DB methods inherited from paliadinDB still work) so cmd/server/main.go can wire a non-nil Paliadin even when neither local tmux nor remote SSH is available. - ErrMRiverUnreachable sentinel for the friendly error code. - classifySSHError translates ssh exit 124 / Permission denied / network errors into the audit-row error_code field. - Compile-time conformance: var _ Paliadin = (*Local|*Remote|*Disabled) PaliadinService(nil). cmd/server/main.go switch: PALIADIN_REMOTE_HOST set → NewRemotePaliadinService else: tmux on PATH → NewLocalPaliadinService else: NewDisabledPaliadinService buildPaliadinRemoteConfig materialises PALIADIN_SSH_PRIVATE_KEY + PALIADIN_KNOWN_HOSTS (multi-line Dokploy secrets) into chmod-600/644 tmpfiles at boot. Defaults: SSHUser=m, SSHPort=22022 (bypasses Tailscale SSH on :22, see design §4.5). Fails fast on a configured remote-host without the matching key/known_hosts secrets. Local-tmux mode now requires `tmux` actually be on PATH at boot (exec.LookPath gate); previously the constructor unconditionally returned a service whose RunTurn would fail at runtime with ErrTmuxUnavailable. The handler-level "friendly error" UX is unchanged: DisabledPaliadinService surfaces ErrPaliadinDisabled which the frontend renders the same way. Build green; existing paliadin_test.go still passes (it tests package-level helpers, untouched). Remote-specific tests land in B4. Refs m/paliad#12 |
||
|
|
56a3dc961e |
refactor(t-paliad-151): extract Paliadin interface; rename PaliadinService → LocalPaliadinService
Phase B step 1 of the Tailscale-SSH route to mRiver. Splits the existing local-tmux PoC into a Paliadin interface with two implementations; the remote-SSH backend lands in a follow-up commit (paliadin_remote.go). Surface: - Paliadin interface — RunTurn, ResetSession, ListRecentTurns, Stats, IsOwner. The handler at internal/handlers/paliadin.go now talks to this instead of the concrete struct. - paliadinDB — embedded base type carrying the audit-table I/O (insertTurnRow, completeTurn, markTurnError, markTurnAbandonedOrError) plus the read-side queries (IsOwner, ListRecentTurns, Stats). Both Local and Remote impls inherit these by embedding paliadinDB so the remote path doesn't have to duplicate any DB code. - LocalPaliadinService — the renamed PoC backend. Identical behaviour to the previous PaliadinService; only the type name and method receivers change. Method receivers split: tmux-specific operations (RunTurn, ResetSession, ensurePane, sendToPane, pollForResponse, etc.) stay on *LocalPaliadinService; DB-only operations promote to *paliadinDB. Wiring: - internal/handlers/handlers.go — Paliadin field becomes the interface type; Register() unchanged. - cmd/server/main.go — calls NewLocalPaliadinService instead of NewPaliadinService. The remote-vs-local switch on PALIADIN_REMOTE_HOST lands in B5. Tests in paliadin_test.go all green — they test package-level functions (splitTrailer, countChips, approxTokenCount, sanitiseForTmux, PaliadinOwnerEmail) and don't touch the renamed struct. No behaviour change on the local-tmux path. Refs m/paliad#12 |
||
|
|
db4279d148 |
fix(t-paliad-152): /api/events honours direct_only — Fristen/Termine subtree toggle works again
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.
|
||
|
|
4e1d311a9c |
feat(t-paliad-149) PR2 step 1/3: backend — migration 061 + CardLayoutService + CardsPreview
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.
|
||
|
|
a5f7b5009b |
feat(t-paliad-149) PR1 step 2/3: frontend rewrite — chips + pin star + last-view restore
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.
|
||
|
|
8412328dec |
feat(t-paliad-149) PR1 step 1/3: backend — migration 060 + PinService + BuildTreeWithOptions
Migration 060 (paliad.user_pinned_projects): per-user, RLS owner-only, ON
DELETE CASCADE on both FKs.
PinService (Pin / Unpin / IsPinned / PinnedSet / ListPinned): visibility-
gates pin (can't pin what you can't see) but not unpin (so users can clean
up after losing access). PinnedSet returns a map for O(1) lookups during
tree stitching.
ProjectService.BuildTreeWithOptions extends BuildTree with chip-driven
filtering. New ProjectTreeNode fields are additive (Pinned,
InheritedVisibility, OpenDeadlinesSubtree, OverdueDeadlinesSubtree,
MatchKind) so the old BuildTree(ctx, userID) call still works for legacy
callers. New options:
Scope: All / Mine / Pinned (Mine + Pinned both expand to path-closure
with InheritedVisibility flag on greyed ancestors)
StatusIn / TypeIn: chip-narrowing whitelists
HasOpenDeadlines: per-node or subtree-aggregated, depending on
IncludeSubtreeCounts
SearchTerm: case-fold contains on title/reference/clientmatter, then
prune to {matches ∪ ancestors ∪ descendants} with match_kind tagged
IncludeSubtreeCounts: post-order DFS sums, O(N)
GET /api/projects/tree gains query params: scope, status, type,
has_open_deadlines, q, subtree_counts. Zero query string preserves
legacy behaviour.
POST/DELETE /api/projects/{id}/pin and GET /api/user-pinned-projects
wired. Service registered in cmd/server/main.go and dbServices.
build + vet clean.
Design: docs/design-projects-page-2026-05-07.md §4.7, §8.1, §8.3.
|
||
|
|
efaa7787af | Merge remote-tracking branch 'origin/main' into mai/kepler/inventor-profession-vs | ||
|
|
8d714dd95e |
fix(t-paliad-146): gate Paliadin to owner email in code, drop PALIADIN_ENABLED
m's call (2026-05-07 21:52): "remove the export variable, that is bad form. It should be connected only to my account." The PALIADIN_ENABLED env var was a deploy-time toggle: easy to mis-flip, splits prod/dev behaviour, and reads as "could be turned on for anyone." Replaced with a per-request gate in code: services.PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com" handlers/paliadin.go now gates every entry point through requirePaliadinOwner, which looks up paliad.users.email by the caller's UUID and returns 404 (not 403 — pretend the route doesn't exist) for anyone else. Routes register unconditionally; the gate is in the code, not the deploy. main.go wires PaliadinService whenever DATABASE_URL is set and logs the owner identity at boot. CLAUDE.md drops the PALIADIN_ENABLED row and gains an explanatory note about the in-code gate. Sidebar entries (Paliadin under Übersicht; Paliadin Monitor under Admin) now render with display:none, revealed by sidebar.ts after /api/me confirms the caller's email matches PALIADIN_OWNER_EMAIL — same fail-closed pattern the Admin group already uses. Side-effect for ops: paliad.de production now serves the routes too, but only to m, and only successfully if the host has tmux + claude in PATH (which Dokploy doesn't). m hitting /paliadin from prod gets a "tmux unavailable" — clear failure mode, not a security concern. One new test (TestPaliadinOwnerEmail_IsLowercaseStable) keeps the constant aligned with migration 023's seed so a future rename of m's account doesn't silently strand the gate. All existing tests pass. |
||
|
|
0b4de1c645 |
feat(t-paliad-148) commit 6/6: deprecation notes + grep sweep
Mark the legacy Role* constants in project_service.go as DEPRECATED.
They stay defined for one release because team_service.go still writes
the deprecated shadow column via legacyRoleFromResponsibility; follow-up
migration 058 (t-paliad-149) retires both the column and the constants.
Final grep sweep clean: no live-code call sites remaining for
project_teams.role outside of:
- the deprecated legacyRoleFromResponsibility mapper (intentional)
- team_service.go RETURNING + SELECT (reads the shadow column for
the JSON .role field still surfaced for the deprecation window)
- migrations 018/023/054/055 (historical, not modified)
Test suite green across all packages: auth, branding, calc, changelog,
handlers, offices, services. Frontend bun build clean (1723 i18n keys).
|
||
|
|
9184e9b0ef |
feat(t-paliad-148) commit 4/6: reminder + deadline + derivation cleanup — pt.role → pt.responsibility
reminder_service.go: BuildDigest audience predicate switches the
"project lead anywhere on the path" branch from `pt.role = 'lead'` to
`pt.responsibility = 'lead'`. Two SQL sites + comment updated.
deadline_service.go: assertCanAdminProject (Reopen permission) switches
from `pt.role IN ('admin','lead')` to `pt.responsibility = 'lead'`.
The legacy 'admin' was already dead since t-paliad-051 — never present
in project_teams.role to begin with — so this also drops a slow leak.
Doc comments + error message updated.
derivation_service.go: ListDescendantStaffed SELECTs both `pt.role` and
`pt.responsibility`, returns the new column to the team-tab "from
descendants" subsection (so the firm-tier badge + responsibility pill
both render). ORDER BY switches to responsibility.
Build + vet clean. Pure-Go tests pass.
|
||
|
|
7b66c4d035 |
feat(t-paliad-146): Paliadin PoC — tmux-Claude in-app AI buddy
Phase 0 of the Paliadin design (docs/design-paliadin-2026-05-07.md
§0.5). m-only laptop scope, gated behind PALIADIN_ENABLED=false on
prod. Lifts the goldi/mVoice tmux-Claude pattern (mVoice/server.py:
250-380) into a Go service: long-lived `claude` pane in a tmux
session, prompts in via `tmux send-keys -l`, responses out via a
per-turn file (/tmp/paliadin/{turn_id}.txt) the system prompt
instructs Claude to write.
What landed
-----------
- migration 058_paliadin_poc — paliad.paliadin_turns audit table
(full prompt + response stored at PoC scope; redaction returns
at production v1 per design §3.3). RLS: user sees own,
global_admin sees all.
- internal/services/paliadin.go — the orchestrator. ensurePane()
finds-or-creates the tagged tmux window, sendToPane sends the
framed [PALIADIN:turn_id] envelope, pollForResponse reads the
per-turn file, splitTrailer parses the [paliadin-meta] block
Claude appends to every reply (used_tools, rows_seen,
classifier_tag).
- internal/services/paliadin_prompt.go — the system prompt sent
once to a fresh Claude pane. Defines the response protocol
(Write-to-file + meta trailer), the action-chip marker syntax,
the visibility-gate rule (paliad.can_see_project required in
every project-scoped query), and 9 SQL recipes covering m's
paliad data + cross-schema youpc case-law lookup.
- internal/handlers/paliadin.go — POST /api/paliadin/turn kicks
off the work in a goroutine and returns an SSE URL; GET
/api/paliadin/stream/{id} relays per-turn channel events
(meta/content/end/error/ping) to EventSource. Routes register
ONLY when PaliadinService is wired — paliadinSvc nil → no
handlers exist, prod surface is clean.
- /admin/paliadin dashboard — global_admin-only. Shows total
turns, last-7-days, median/p90 duration, tool-use rate (the
load-bearing §0.5.7 metric), abandon rate, classifier
histogram, daily sparkline, top prompts, recent turn log.
Powered by PaliadinService.Stats() + ListRecentTurns().
- frontend: paliadin.tsx + client/paliadin.ts (chat panel with
starter prompts, EventSource consumer, typewriter render of
one-shot content blob, citation-chip parser, "Stop" + "New
conversation" buttons, localStorage history); admin-paliadin
pair (read-only stats dashboard).
- Sidebar: Paliadin entry under Übersicht (ICON_SPARKLE);
Paliadin Monitor under Admin.
- 36 i18n keys (DE+EN), CSS for chat panel + dashboard.
- main.go: PaliadinService wires only on PALIADIN_ENABLED=true,
with PALIADIN_TMUX_SESSION + PALIADIN_RESPONSE_DIR overrides.
Logs visibly so the operator can confirm at boot.
- CLAUDE.md: ANTHROPIC_API_KEY row updated (PoC doesn't need it
— Claude CLI uses m's subscription; key reserved for future
production-v1). New rows for the three PALIADIN_* env vars.
Tests
-----
- 7 unit tests on the trailer parser, chip counter, token approx,
and tmux-input sanitiser. All pass. The trailer parser is
load-bearing for monitoring; an unobserved parser bug = silent
dashboard rot.
What's NOT in v1 (stays deferred)
---------------------------------
- The Anthropic API client (production v1, gated on PoC success
per §0.5.7).
- BYO-AI / OpenAI adapter.
- Per-user rate limiting.
- Multi-replica SSE bus.
- Mascot / avatar SVG.
- Persistent threads (history is browser localStorage only).
How to use locally
------------------
$ export PALIADIN_ENABLED=true
$ ./paliad
# browse /paliadin → type a question → answers stream back
# /admin/paliadin shows the monitoring dashboard
Migration: 058 (skips fritz's t-147 on 057). Safe on prod
because PALIADIN_ENABLED defaults to false; the table is created
but no routes touch it until the env var flips.
|
||
|
|
e6937d232e |
feat(t-paliad-148) commit 3/6: TeamService + UserService + Models + Handlers — write profession + responsibility
Models:
- ProjectTeamMember.Responsibility (new) + .Role (kept as deprecated shadow). JSON exposes both during the deprecation window.
- ProjectTeamMemberWithUser.UserProfession — populated by reads so the team-tab UI can render the firm-tier badge.
- User.Profession (*string) — structured firm-tier driving the approval ladder. Distinct from JobTitle (display) and GlobalRole (tool admin).
TeamService:
- AddMember signature kept as (callerID, projectID, userID, responsibility) — third arg renamed conceptually. Accepts the new responsibility enum and writes both legacy `role` (via legacyRoleFromResponsibility helper) and `responsibility` to keep the deprecated shadow consistent.
- ListDirectMembers + ListEffectiveMembers SELECT both `pt.role`, `pt.responsibility`, and `u.profession`. ORDER BY switches from pt.role to pt.responsibility.
- legacy isValidRole removed (unused after switch to IsValidResponsibility).
UserService:
- CreateUserInput + AdminCreateInput + AdminUpdateInput accept Profession. Self-service onboarding defaults to 'associate' when empty. AdminCreate likewise. AdminUpdate empty-string clears to NULL (external collaborator). Invalid values rejected with ErrInvalidInput.
- INSERT statements write the new column on both Create paths.
ProjectService.Create:
- Auto-add-creator INSERT writes responsibility='lead' alongside legacy role='lead'.
Handlers:
- POST /api/projects/{id}/team accepts `responsibility` (preferred) and falls back to legacy `role` for one release while frontend migrates.
Build + vet clean. Pure-Go tests pass.
|
||
|
|
6506864730 |
feat(t-paliad-148) commit 2/6: ApprovalService + DerivationService — tuple-with-gate ladder
Rewires the 4 SQL ladder sites in approval_service.go (canApprove,
hasQualifiedApprover, ListPendingForApprover, PendingCountForUser) to read
the new tuple: project_teams.responsibility ∈ {lead, member} AND
users.profession at or above the threshold. observer/external rows close
the gate even if the user's profession would otherwise qualify — that's
the project-level call.
approval_levels.go renamed levelOf → professionLevel and added
responsibilityOpensGate helper. New constants: ProfessionPartner /
ProfessionOfCounsel / … and ResponsibilityLead / ResponsibilityMember /
ResponsibilityObserver / ResponsibilityExternal. New validators
IsValidProfession + IsValidResponsibility. RoleSeniorPA kept as legacy
alias for the one remaining call site that hasn't migrated yet.
CRITICAL trap pinned by TestProfessionLevel_NilIsZero: NULL profession
returns 0, never silently defaults to associate. External collaborators
must stay ineligible.
derivation_service.go: requireWritePermission switches from pt.role='lead'
to pt.responsibility='lead' — project-management writes gate on the
project responsibility, not the firm tier. EffectiveProjectRole replaced
by UserProjectAuthorityLevel (thin wrapper over the SQL function in
migration 057). The legacy method was unused dead code despite t-139
design intent.
Tests extended: profession ladder, responsibility gate, NULL trap,
new validators. Build + vet clean.
|
||
|
|
52ee319fd8 |
feat(t-paliad-147): bulk team email — send to filtered selection from /team page
Implements issue #7. Adds an "E-Mail an Auswahl" button on /team that sends personalised emails to a filter-narrowed subset of the team. Each recipient gets their own envelope (per-recipient privacy, no shared To: list); From stays on the SMTP infrastructure address with Reply-To set to the human sender so replies route correctly without forging DKIM/SPF. Backend - Migration 057: paliad.email_broadcasts (subject, body, sender_id, template_key, recipient_filter jsonb, recipient_user_ids uuid[], send_report jsonb, sent_at). RLS: senders read own rows, global_admin reads all; inserts must self-attribute. No CHECK-constraint extension to partner_unit_events — broadcasts get their own table per the lock. - BroadcastService (internal/services/broadcast_service.go): validates subject/body/recipient cap (100), enforces project_lead-OR-global_admin, persists audit row, dispatches via 5-deep goroutine pool with 15s per-send timeout. Send report (sent/failed counts + per-recipient errors) is captured back into email_broadcasts.send_report. - markdown.go: minimal Markdown→safe HTML renderer (paragraphs, **bold**, *italic*, `code`, [text](url), bullet lists). Inputs are HTML-escaped first; only whitelisted tags re-emitted. Script tags and javascript: URLs can't slip through. - Placeholder substitution: {{name}}, {{first_name}}, {{role_on_project}} (whitespace tolerated). Unknown {{...}} tokens pass through unchanged. - mail_service.go: buildMIMEWithReplyTo helper layers a Reply-To header on top of the existing multipart/alternative envelope. - TeamService.ListMembershipsIndex: visibility-gated user→project_ids index. Powers the /team project multi-select filter without N round trips per project. - Handlers: POST /api/team/broadcast (gateOnboarded; service enforces authority), GET /api/team/memberships, GET /api/admin/broadcasts (list), GET /api/admin/broadcasts/{id} (detail), GET /admin/broadcasts (page). /admin/broadcasts is gateOnboarded (not adminGate) so leads can see their own sends; the service applies the per-row visibility filter. Frontend - /team gains a project multi-select chip dropdown (visible projects loaded from /api/projects, intersected against the memberships index) alongside the existing office and role filters. - "E-Mail an Auswahl (N)" button appears only when canBroadcast() is true (global_admin always; non-admin needs lead-ship on selected projects, or at least one project when no filter is set). Server still re-checks per send. - Compose modal (broadcast.ts): subject + body textarea + optional template dropdown (loads existing email templates and strips Go-template directives) + recipient preview (first 5 + expand) + send. Hard-blocks empty subject/body and N=0. Shows per-send report on success. - /admin/broadcasts viewer: read-only list with click-row-to-expand detail (subject, body, recipient list, send_report counts). Tests - broadcast_service_test.go: placeholder substitution table-driven, Markdown safe-render incl. XSS guards (<script>, javascript: URLs), validation cases (empty subject/body, recipient cap, invalid email), signature rendering DE/EN. - broadcast_service_live_test.go: end-to-end Send + List + Get + visibility rules (lead can send on own project, member cannot, admin sees all, member can't read lead's row). Skips when TEST_DATABASE_URL is unset. i18n: 60 new keys × 2 langs (broadcast modal labels, error messages, recipient summary, /admin/broadcasts viewer, common.close/loading/forbidden/ load_error). |
||
|
|
cda4b4083d | Merge: t-paliad-144 A1 — backend substrate + Custom Views API (migration 056 paliad.user_views + ViewService 4-source union + FilterSpec/RenderSpec validators + SystemView registry + UserViewService + 9 HTTP handlers) | ||
|
|
b516201110 |
feat(t-paliad-144 A1): backend substrate + Custom Views API
Phase A1 of the data-display-model rethink (m/paliad#5). Backend-only; no user-visible change in A1. A2 (frontend) lands separately. What's new: - Migration 056: paliad.user_views table with RLS scoped to caller (user_views_owner_all on auth.uid()=user_id). Composite UNIQUE (user_id, slug). No is_system flag — system defaults stay code- resident per Q8 lock-in. - internal/services/filter_spec.go (+test): structured FilterSpec with Sources / Scope / Time / Predicates. Server-side validator rejects unknown sources, duplicate sources, conflicting scope modes, horizon=all without explicit projects (Q26 clamp), and every per-source enum (deadline.status, appointment_types, project_event kinds, approval_request status / viewer_role). - internal/services/render_spec.go (+test): RenderSpec with three shapes (list / cards / calendar — Q4 lock-in 2026-05-07). Per-shape config kept separately so flipping shapes preserves tweaks. Validator over column / sort / density / group_by / default_view enums. - internal/services/system_views.go (+test): code-resident SystemView definitions for dashboard / agenda / events / inbox / inbox-mine. Reserved-slug list (Q23) prevents user-views from colliding with top-level URLs. Case-folded matching. - internal/services/view_service.go: extends EventService with RunSpec — runs a FilterSpec across all four substrate sources (deadline + appointment + project_event + approval_request) and merges into []ViewRow sorted by event_date. ViewRow is a discriminated projection (kind + common header + per-source Detail json.RawMessage). Q17 fail-open attribution: returns inaccessible_project_ids for explicit-scope queries where the caller can't see some IDs. - internal/services/user_view_service.go (+test): CRUD on paliad.user_views — Create (server-assigns sort_order MAX+1 in tx), GetBySlug, GetByID, Update (partial), Delete, Touch (last_used_at), MostRecent. Reserved-slug + slug-format validators on every write. - internal/handlers/views.go: nine HTTP handlers wiring the endpoints (GET/POST/PATCH/DELETE /api/user-views/..., POST /api/user-views/{id}/touch, POST /api/views/run, POST /api/views/{slug}/run, GET /api/views/system). - main.go + handlers.go + projects.go: wire UserViewService into the bundle; conditional route registration when both UserView + Event services are present. Pure-Go tests (no DB): 32 cases pass — filter spec validators, render spec validators, system view registry, reserved slugs. Live-DB tests (skip when TEST_DATABASE_URL unset): 12 cases covering create / list / get / uniqueness / update / delete / touch / most-recent / reserved-slug / bad-slug / empty-name / invalid-spec. Coexists with t-139 (in-flight on noether's other branch) and t-138 (shipped) without coordination commits — RunSpec uses the existing visibility predicate that t-139's migration 055 will extend with derivation. Approval-request source delegates to ApprovalService.ListPendingForApprover / ListSubmittedByUser (both already extended for derived_peer authority in t-139 Phase 3). Files: 15 changed, 3134 insertions. Build clean. Tests green. |
||
|
|
bfc48b1420 |
fix(t-paliad-143): derived team members all show 'Attorney' + Herkunft collapses multi-unit users
Two related bugs on /projects/{id} Team tab → "Abgeleitet (Partner Unit)":
1. **All derived members labeled 'Attorney'.** Migration 055 added
partner_unit_members.unit_role with DEFAULT 'attorney' but never exposed
the column in the admin UI. So 100% of pum rows are 'attorney' and
Siemens AG's derive_unit_roles=['pa','senior_pa','attorney'] config
surfaces every member as 'attorney' even when they're really PAs.
2. **Multi-unit users collapsed to one source.** ListDerivedMembers used
ROW_NUMBER() OVER (PARTITION BY user_id) WHERE rn=1 — closest-attachment
wins, every other unit-membership dropped. Judith Molarinho Vaz +
Sabrina Franken belong to BOTH Lehment AND Plassmann; UI showed only one.
**Backend** (internal/services/derivation_service.go):
- DerivedMember.Memberships []DerivedMembership replaces scalar
UnitID/UnitName/UnitRole. DeriveGrantsAuthority becomes bool_or across
all source attachments (any granting → true).
- ListDerivedMembers SQL: jsonb_agg(DISTINCT jsonb_build_object(...)) +
bool_or(derive_grants_authority), GROUP BY user. One row per user, every
(unit, role) pair preserved. Memberships sorted by unit_name in Go (PG
doesn't allow ORDER BY inside DISTINCT-aggregated jsonb_agg).
- DerivedMembershipList implements sql.Scanner so the jsonb column maps
directly into the Go struct. Pinned by unit test.
**Frontend** (projects-detail.ts):
- DerivedMember interface mirrors the new shape. Herkunft renders every
(unit, role) source — single-unit users render as before
("über: **Lehment** [Sicht]"); multi-unit users render
"über: **Lehment** (Attorney), **Plassmann** (PA) [Sicht & 4-Augen]".
- Role column shows distinct unit_role values.
**Frontend** (admin-partner-units.ts):
- Member modal gains a per-row <select> with the 5 unit_role options. On
change, PATCH /api/partner-units/{id}/members/{user_id}/role (endpoint
already shipped in t-paliad-139 Phase 2). Disables during request,
rolls back the prior selection on failure.
- 2 new i18n keys (DE + EN): admin.partner_units.member.role,
admin.partner_units.feedback.role_updated.
- New CSS for .partner-unit-member-item flex layout + .pu-role-select.
**Out of scope** (per design): semantics of derive_unit_roles, new
unit_role values beyond the 5-row CHECK, the bigger profession-vs-project-
role redesign (#6).
**Verification**:
- Live SQL dry-run on Siemens AG (61e3fb9e-29fb-44aa-867e-a89469e2cacb)
returns Judith + Sabrina each with [{Lehment,attorney},{Plassmann,attorney}]
and derive_grants_authority=true (Plassmann grants authority).
- DerivedMembershipList.Scan unit-tested for nil / single / multi /
unsupported-type cases.
- Go build + tests pass; frontend build clean (1608 i18n keys).
After merge, m can verify on prod: /admin/partner-units → Plassmann →
set Judith to 'pa' → reload Siemens AG Team tab → Judith shows as 'PA'
with Herkunft "über: **Lehment** (Attorney), **Plassmann** (PA)".
|
||
|
|
a61c1490e3 |
feat(t-paliad-139): Phase 3 — derived_peer authority extension to t-138 approval gate
Wires DerivationService.EffectiveProjectRole into the t-paliad-138
approval ladder so partner-unit-derived members with derive_grants_authority=true
can act as approvers (per design §4.2). When they sign off, the audit row
records decision_kind='derived_peer' — a third value alongside the existing
'peer' and 'admin_override' — so the chronology discloses the derivation
chain.
Schema (migration 055 update)
-----------------------------
- paliad.approval_requests.decision_kind CHECK extended to accept
'derived_peer'. Down migration restores the t-138 two-value CHECK.
Live SQL dry-run confirmed the new value is accepted.
Service layer
-------------
- approval_levels.go: new constant DecisionKindDerivedPeer.
- approval_service.go (4 sites widened with the derivation EXISTS branch):
1. canApprove — third resolution step after global_admin + direct/
ancestor team membership: matches partner-unit-derived members
on path with derive_grants_authority=true and a unit_role whose
approval_role_from_unit_role mapping meets the threshold.
Returns DecisionKindDerivedPeer when this branch is the one that
passed.
2. hasQualifiedApprover (the deadlock-check at submit time) —
widened so a project with no direct approvers but an authority-
granting unit attachment is still submittable.
3. ListPendingForApprover (the /inbox query) — third UNION ALL
branch so derived authority sees their queue.
4. PendingCountForUser (the bell-badge query) — same widening so
derived authority sees the count tick.
All four queries reuse paliad.approval_role_from_unit_role(text) added
by Phase 2 of migration 055.
Frontend
--------
- 2 i18n keys (DE+EN): approvals.decision_kind.derived_peer →
"Genehmigt durch abgeleitetes Mitglied (Partner Unit)" / "Approved by
derived member (Partner Unit)". Verlauf rendering of the third
decision_kind value works through the existing translateEvent /
decision_kind switch with no other change. 1606 keys total.
Strict-default unchanged
------------------------
Derived members are visibility-only by default. Authority requires the
project lead/admin to explicitly flip derive_grants_authority=true on the
project_partner_units row (UI on /projects/{id} Team tab, Phase 2). This
preserves the m-locked Q12 stance.
Phase 3 closes the t-paliad-139 implementation. m's bug closes (Phase 1),
the derivation schema is in place (Phase 2), and approval authority
flows through the new ladder (Phase 3).
|
||
|
|
544bb63684 |
feat(t-paliad-139): Phase 2 — partner-unit derivation schema + Team-tab subsections
Migration 055 adds the structural pieces the issue's PA-derivation premise
needed (the design-§1.3 verify-before-trust check found all three were
missing today):
- paliad.partner_unit_members.unit_role text DEFAULT 'attorney'
CHECK ('lead'|'attorney'|'senior_pa'|'pa'|'paralegal') — per-unit role
distinction so derivation can target specific tiers without re-
introducing a firm-wide rank column. The same human can be 'attorney'
in one unit and 'lead' in another.
- paliad.project_partner_units junction (project_id, partner_unit_id,
derive_unit_roles[] DEFAULT {pa,senior_pa}, derive_grants_authority bool
DEFAULT false, attached_at, attached_by) with composite PK and RLS
(read = can_see_project; write = global_admin OR project lead).
- paliad.approval_role_from_unit_role(text) helper used by Phase 3 when
derived authority is consulted by the t-138 ladder.
- paliad.can_see_project extended with one EXISTS branch — derivation
walks the path: a user is visible on P if any (ancestor of P) is
attached to a unit they are a member of with a matching unit_role.
No RAISE EXCEPTION (Maria's build constraint). Day-1 deploy = zero
behaviour change because every existing unit member defaults to
unit_role='attorney' and the default derive_unit_roles is {pa,senior_pa},
so until both diverge no derivation happens.
Backend services
----------------
- DerivationService (new, internal/services/derivation_service.go):
AttachUnitToProject, DetachUnitFromProject, ListAttachedUnits,
ListDerivedMembers (path-walking dedupe by closest attachment),
ListDescendantStaffed (descendant-direct rows excluding ancestor-
already-staffed), EffectiveProjectRole (returns role + source ∈
{direct, ancestor, derived} for the t-138 approval gate in Phase 3).
- PartnerUnitService extensions:
PartnerUnitMemberDetail gains UnitRole (db:"unit_role"). Constants
UnitRoleLead/Attorney/SeniorPA/PA/Paralegal + isValidUnitRole.
SetMemberRole(callerID, unitID, userID, role) with admin gate, prior-
role read in tx, audit emit 'member_role_changed'. ListMembers and
ListWithMembers SELECT projection now includes pum.unit_role.
Handlers
--------
- GET /api/projects/{id}/partner-units → ListAttachedUnits
- POST /api/projects/{id}/partner-units → AttachUnitToProject
- DELETE /api/projects/{id}/partner-units/{unit_id} → DetachUnitFromProject
- GET /api/projects/{id}/team/derived → ListDerivedMembers
- GET /api/projects/{id}/team/from-descendants → ListDescendantStaffed
- PATCH /api/partner-units/{id}/members/{user_id}/role → SetMemberRole
- Services bundle gains Derivation; cmd/server/main.go wires it.
Frontend (Team-tab on /projects/{id})
-------------------------------------
Three new subsections rendered after the existing direct+ancestor table:
- "Aus Unterprojekten" — descendant-direct rows with attribution arrow.
- "Abgeleitet (Partner Unit)" — derived rows with [Sicht] / [Sicht & 4-
Augen] badge per the m-locked honesty rule (§3.5).
- "Partner Units" — attached-unit list with attach/detach controls
(lead/admin only) and a form picker for derive_unit_roles +
derive_grants_authority.
Each subsection is hidden when its data is empty (Partner Units block
also surfaces for managers when empty so they can attach).
Loaders + state in projects-detail.ts; renderTeam orchestrates all
four subsections; renderAttachedUnits owns the unit list + detach
handlers; initAttachUnitForm wires the picker + checkbox role-set.
canManagePartnerUnits gates the attach UI on global_admin OR direct
'lead' on the current project.
i18n keys (DE+EN, ~30 new) under projects.team.section.*,
projects.team.derived.*, projects.team.units.*, unit_role.*. Codegen now
emits 1605 keys (was 1494).
CSS additions: .entity-section-heading (subsection h3),
.derived-badge / .derived-badge--authority, .form-checkbox.
Phase 3 (approval extension to honour derived_peer decision_kind) stacks
on top — gates on EffectiveProjectRole returning ('role','derived') being
wired into the t-138 canApprove + inbox SQL.
|
||
|
|
f8d8ea591d | Merge remote-tracking branch 'origin/main' into mai/noether/inventor-project |