design(t-paliad-145): local chat for teams

Inventor design for in-app messaging surface inside paliad. Three
coordinated sub-designs (surface/visibility, real-time/content/
persistence, integration with existing surfaces) answering all 21
issue-body questions plus 11 inventor follow-ups.

Recommendations:
- Per-project chat + DMs in v1; per-deadline/termin/partner-unit defer
- Per-thread visibility = can_see_project (existing predicate, derivation-aware)
- SSE for real-time (single new long-lived endpoint)
- New: paliad.chat_threads, chat_messages, chat_reads, chat_thread_participants, chat_mentions
- New project_teams.chat_access flag, default OFF for local_counsel/expert
- Approval auto-post into project chat (one auto-event class only)
- Markdown subset, @mentions, entity-refs (#frist-, #projekt-, #termin-, #approval-)
- 5-min author edit window, soft-delete by author or admin
- In-app sidebar badge in v1; PWA push + email digest deferred Phase 2
- Both top-level /chat and per-project Chat tab
- NOT a 5th source in Custom Views (chat is conversation, not events)

Trade-off flagged: adoption risk against existing Slack/WhatsApp.
Recommend m sanity-check with PA colleagues before locking v1 scope.

Migration 057. Awaiting m go/no-go before coder shift.
This commit is contained in:
m
2026-05-07 16:03:05 +02:00
parent 95f6f03cda
commit dd4f563212

View File

@@ -0,0 +1,947 @@
# Design: Local Chat for Teams (t-paliad-145)
**Status:** READY FOR REVIEW
**Author:** noether (inventor)
**Issue:** [m/paliad#8](https://mgit.msbls.de/m/paliad/issues/8)
**Date:** 2026-05-07
**Branch:** `mai/noether/inventor-local-chat-for`
---
## §0 TL;DR
A new chat surface inside paliad: **per-project threads + 1:1/small-group DMs**, with @mentions and entity references (`#frist-…`, `#projekt-…`, `#approval-…`). Real-time delivery via **SSE** (one new long-lived endpoint). New schema: `paliad.chat_threads` + `paliad.chat_messages` + `paliad.chat_reads` + `paliad.chat_thread_participants` + `paliad.chat_mentions`. Visibility composes the existing `paliad.can_see_project` predicate; write-access adds a `chat_access` flag on `project_teams` (default ON for internal roles, OFF for `local_counsel`/`expert`); `observer` is read-only.
In-app badge in the sidebar (alongside the existing approvals bell). **No PWA push, no email digest, no attachments, no search-across-threads in v1** — all deferred to Phase 2. Markdown subset (bold, italic, code, lists, links, blockquote — no headings, no images). Edit window 5 min by author; soft-delete by author or admin. System auto-post into project chat when an approval is requested on that project (the only auto-event in v1).
Total scope: one migration (`057_chat`), one new service (`ChatService`) + an in-process pubsub (`ChatBus` interface — pg_notify implementation later when paliad multi-replicas), eight HTTP endpoints, one new top-level page `/chat`, one new `/projects/{id}` tab. Estimated ~35004500 LoC for the bundled v1 ship; phasable into 3 PRs (schema + service core, real-time + frontend, mentions + auto-post).
**Trade-off flagged up-front (read §9.1 before approving):** chat-in-paliad collides with HLC's existing internal comms (Slack/Teams/WhatsApp). Compliance is the cited differentiator, but adoption depends on whether team members actually move "Anna, kannst du auf meine Frist 16.05. drauf schauen?" from WhatsApp into paliad. Recommend m sanity-check this with two PA colleagues before locking the v1 scope.
---
## §1 Premises verified live (2026-05-07)
Before designing on top, I verified each load-bearing claim against the running system rather than CLAUDE.md / memory:
| Claim | Source | Verification |
|---|---|---|
| `paliad.notifications` does not exist | issue body Q5/§References | `information_schema.tables WHERE table_schema='paliad'` — confirmed absent. Only `paliad.reminder_log` (email dedup). |
| Service worker is cache-only (no push handler) | brand expectation | `frontend/public/sw.js` — only `install`/`activate`/`fetch` handlers. No `push`, no VAPID keys, no `web-push` Go dep. |
| Supabase realtime is not enabled on this Postgres | infra | `pg_extension WHERE extname='supabase_realtime'` → empty. Adding it is a separate decision (changes paliad's Postgres surface). |
| `paliad.can_see_project()` already extended for derivation | t-139 Phase 2 | Migration 055 added the partner-unit branch; visibility predicate is the canonical entry. |
| `project_teams.role` enum is `{lead, of_counsel, associate, senior_pa, pa, local_counsel, expert, observer}` | t-138 + t-139 | `pg_constraint` on `project_teams_role_check`. Confirms eight values; `observer` is the read-only one. |
| Sidebar has a bell badge `id="sidebar-inbox-badge"` for approvals | t-138 | `frontend/src/components/Sidebar.tsx:118`. Same pattern reused for chat unread badge. |
| BottomNav has exactly 5 slots (Start / Projekte / + / Agenda / Menü) | mobile UX constraint | `frontend/src/components/BottomNav.tsx`. Adding chat to bottom-nav would need swap-out — deferred to per-project tab on mobile. |
| Migration tracker is at version 56 (`056_user_views`) | t-144 A1 | `paliad_schema_migrations` row. Next migration is **057**. |
| `paliad.notes` exists as annotations on deadlines/appointments/project_events | data model v2 | Different concept from chat (annotations, not conversation). Document the distinction so they don't collide. |
| Single web replica today on Dokploy | docker-compose.yml | One `web` service, no horizontal scaling. SSE in-process bus is safe v1; document multi-replica migration path. |
| `feature-roadmap.md` mentions "AI chat" | feature-roadmap.md | This is a different concept (Claude-grounded RAG over paliad content, blocked by no-Anthropic-API decision). Reserve `/chat` for human-to-human; AI chat goes elsewhere if it ever ships (`/ask`, `/assist`, etc.). |
**Doc-vs-live conflicts encountered:** none material. CLAUDE.md and memory are consistent with the live substrate for this task.
---
## §2 What v1 is and what it isn't
### 2.1 In scope (v1)
- **Per-project threads**, one chat per project node. Visible to: same set as `can_see_project()` (direct + ancestor + derived). One thread auto-provisioned on first access.
- **Direct messages (DMs)**: 1:1 + small-group ad-hoc. Recipient picker pulls from any user the caller can already see (i.e. someone who shares a visible project).
- **Plain-text + Markdown subset** (bold, italic, code inline + block, bullet/numbered lists, blockquote, auto-linked URLs). No headings, no images, no inline HTML.
- **@mentions** and **entity references** (`#frist-<short_id>`, `#projekt-<slug>`, `#termin-<short_id>`, `#approval-<short_id>`).
- **Edit** within 5 min, by author. Tombstone-style **delete** by author or admin.
- **Real-time delivery** via SSE.
- **In-app sidebar badge** with unread count.
- **Read marker** per (user, thread).
- **System auto-post**: when an approval request is created on this project's deadlines/appointments, system message in chat ("Anna hat Genehmigung angefordert: …"). One auto-event class only.
- **Chat tab on `/projects/{id}`** (deep-link entry).
- **Top-level `/chat` page** (global view + DM landing).
### 2.2 Out of scope (v1, deferred)
| Feature | Why deferred | Phase |
|---|---|---|
| PWA push notifications | Needs VAPID + push subscription endpoint + SW push handler. Non-trivial; chat MVP works without it. | 2 |
| Email digest of unread chats | Reminder system already saturates email; digest math + SMTP load. | 2 |
| File attachments | `paliad.documents` already exists as the canonical document store; chat reuse is a Phase 2 plumbing exercise. | 2 |
| Cross-thread search | Postgres FTS + visibility join is a separate optimisation. v1 has thread-scoped LIKE search. | 2 |
| Per-deadline / per-termin micro-threads | High-noise risk. Project chat with `#frist-…` references covers most uses. | 3 |
| Partner-unit room ("cross-cutting team room") | Semantically maps to a partner_unit-scoped chat; v2 once project chat usage validates. | 3 |
| Reactions (👍 / 👎) | Issue body lists this as Phase 2. | 2 |
| Threaded sub-replies (Slack-style) | UX complexity + count-math change. Flat threads for v1. | 3 |
| End-to-end encryption | HLC's storage assumptions are server-trusted; defer. | — |
| External-firm chat (opposing counsel etc.) | Compliance + identity boundary. Out of scope, possibly forever. | — |
---
## §3 Sub-design A — Surface set, visibility, permissions
Answers Q1, Q2, Q3, Q19, Q20.
### 3.1 Surface set (Q1)
**Recommendation: project chat + DMs in v1. Defer per-deadline/per-termin/partner-unit/topical.**
| Surface | v1? | Rationale |
|---|---|---|
| Per-project | ✅ | Already-resolved team set, contextual references, replaces "@channel"-style coordination on a project. The high-leverage default. |
| DM (1:1) | ✅ | Replaces "schick mir kurz das Aktenzeichen" WhatsApps. Recipient set = anyone the caller can see. |
| DM (small group, ad-hoc 38) | ✅ | Same plumbing as 1:1 — participants set instead of pair. No project context required. |
| Per-deadline | ❌ Phase 3 | High-noise risk; project chat with `#frist-1234` reference does 95% of the same work. Revisit if usage shows demand. |
| Per-termin | ❌ Phase 3 | Same reasoning as per-deadline. |
| Partner-unit room | ❌ Phase 3 | Maps cleanly onto `partner_units` once we see the surface gain traction. Extra surface for v1 = extra surface to maintain. |
| Cross-cutting topical rooms | ❌ Defer | No clear v1 use case; would need user-driven creation + naming + discovery. Wait for organic demand. |
### 3.2 Visibility model (Q2 — hierarchy)
**Recommendation:**
- **Each project node has its own thread.** A `Client` chat is a separate thread from its child `Litigation`, which is separate from each `Patent` and `Case`. Threads are NOT aggregated up the hierarchy.
- **Read access per thread** = the existing `paliad.can_see_project(project_id)` predicate (which already includes direct + ancestor team, derived partner-unit members, and global_admin). This means a member added at `Client` level sees the Client thread *and* every descendant's thread (because they can already see those projects' deadlines/appointments). Conversely, a member added only at `Case` level sees only the Case thread.
- **Why not aggregate down?** Aggregating ("Client thread = union of all descendant threads") breaks privacy: Case 14-vs-Müller chat content would surface in the Siemens AG Client thread, visible to all Siemens AG team members. Each project-level thread is its own boundary.
- **Why use per-thread visibility = `can_see_project`?** It mirrors every other visibility decision in paliad (deadlines, appointments, events, approvals). One predicate, one mental model. If t-139's derivation rules change, chat tracks for free.
**Practical example:**
```
Client: Siemens AG ← thread S
├─ Litigation: UPC München patent X ← thread L1
│ ├─ Patent: EP1234567 ← thread P1
│ │ └─ Case: 14-vs-Müller ← thread C1
│ └─ Patent: EP7654321 ← thread P2
└─ Litigation: EPO Opposition ← thread L2
```
A member added at `Client` (Siemens AG) sees S, L1, L2, P1, P2, C1. A member added only at `Case 14-vs-Müller` sees only C1. A derived partner-unit member attached at L1 sees L1, P1, P2, C1.
**Anti-feature flagged:** no "broadcast to whole subtree" on send. If a lead wants to message everyone on every Siemens AG thread, they post to the `Client`-level thread; sub-thread members do not get cross-posted. This is intentional — broadcast is a separate UX (Issue #7 bulk team email) and shouldn't be smuggled into chat.
### 3.3 Approval flow integration (Q3 — t-138 cross-cut)
**Recommendation: chat does NOT replace inbox or email for approval. Instead, on approval-request creation, post a system message into the project chat with a deep-link to `/inbox`.**
Rationale:
- Approvals are structured (approve/reject buttons, decision_kind, audit). Chat is unstructured. Conflating them dilutes the structure.
- But chat is where the team's eyeballs live ambiently. Posting "📌 Anna hat Genehmigung angefordert: Frist 16.05. (Replik einreichen). [Zur Genehmigung →]" surfaces the request without forcing anyone to refresh /inbox.
- **De-dup with email + bell:** the system post is informational only. Email reminder + bell badge stay primary signals. If the approver opens the chat first and clicks the deep-link, they reach /inbox; the bell decrements as soon as they act there.
**Mechanism:**
- `ApprovalService.Submit*` calls `ChatService.PostSystemMessage(threadID, kind="approval_requested", refs={approval_id})` inside the same tx as the approval row insert. If chat post fails, log + continue (approval is the load-bearing record; chat is observability).
- One auto-event class only. NOT every deadline-created / appointment-created. Reason: existing Verlauf already captures those; chat would become a duplicate event log.
- System messages render with a distinct chip/style (`.chat-system-message`) — non-author, no edit/delete affordance for users.
**Anti-feature flagged:** approval *decisions* (approve/reject/revoke) do NOT auto-post. Only the *request* posts. This keeps signal density manageable.
### 3.4 Who can read + write (Q19, Q20)
**Recommendation:**
- **Read**: anyone with `can_see_project` access (direct + ancestor + derived + global_admin). Same predicate as deadlines/appointments. No further gating.
- **Write**: same set, minus:
- `observer` — always read-only on chat (mirrors observer's read-only contract on deadlines/appointments).
- `local_counsel` and `expert` — opt-in per project via a new `project_teams.chat_access` boolean. Default `false` for these two roles, `true` for everyone else. Project lead or global_admin can flip the toggle on `/projects/{id}/settings/team`.
**Schema delta (in 057):**
```sql
ALTER TABLE paliad.project_teams
ADD COLUMN chat_access boolean NOT NULL DEFAULT true;
UPDATE paliad.project_teams SET chat_access = false
WHERE role IN ('local_counsel', 'expert');
CREATE INDEX project_teams_chat_idx
ON paliad.project_teams (project_id, user_id) WHERE chat_access = true;
```
**Why a boolean instead of a separate `chat_access_role` enum?** External counsel/expert participation in chat is binary in practice ("included or excluded"). Granular ladder isn't needed. If product later wants "external can read but not write", we revisit.
**Why default ON for internal roles?** Path of least surprise: paliad already gives them visibility on all project artifacts; chat read+write is the same trust level.
**Why default OFF for external?** Compliance is the marquee differentiator m cited. External counsel chatting in paliad creates audit/disclosure surface that internal counsel may not anticipate. Default OFF puts the lead in control.
**Derived members (partner-unit derivation, t-139)**: read = visibility (yes). Write = also yes by default (they can already see the project's other artifacts; chat is no more privileged). Derived members do NOT need `chat_access=true` — that flag is on `project_teams` only, which derived members don't appear in. The derivation branch in the read query already covers them; for write, the service-layer check is "caller has any access (direct/ancestor/derived/admin) AND if direct, role != observer AND chat_access != false".
**Service-layer write predicate (Go):**
```go
func (s *ChatService) canPostToProject(ctx context.Context, callerID, projectID uuid.UUID) (bool, error) {
// global_admin shortcut
if isGlobalAdmin, _ := s.users.IsGlobalAdmin(ctx, callerID); isGlobalAdmin {
return true, nil
}
// Direct/ancestor membership with role != observer AND chat_access = true
var directOK bool
err := s.db.QueryRowxContext(ctx, `
SELECT EXISTS(
SELECT 1 FROM paliad.project_teams pt
WHERE pt.user_id = $1
AND pt.role <> 'observer'
AND pt.chat_access = true
AND pt.project_id = ANY(string_to_array((SELECT path FROM paliad.projects WHERE id = $2), '.')::uuid[])
)`, callerID, projectID).Scan(&directOK)
if err != nil { return false, err }
if directOK { return true, nil }
// Derived (partner-unit) membership: observer/external-flag not relevant — derivation has no role
return s.derivation.IsDerivedMember(ctx, callerID, projectID)
}
```
`canRead` is the simpler `can_see_project` mirror — no observer/external gating.
---
## §4 Sub-design B — Real-time, content, persistence
Answers Q4Q15, Q21.
### 4.1 Real-time architecture (Q4)
**Recommendation: Server-Sent Events (SSE).**
| Option | v1 fit | Notes |
|---|---|---|
| (a) Polling | ❌ | Cheap to ship but lossy under tab-sleep, doubles the API load on every active tab. Already a pain point for the bell badge. |
| (b) **SSE** | ✅ | One-way push, native in Go's `net/http` via `http.Flusher`, EventSource auto-reconnects, single endpoint, no per-message connection. Traefik forwards `text/event-stream` with no special config beyond disabling response buffering. |
| (c) WebSockets | Defer | Bidirectional we don't need (post is a regular POST + bus publish). Adds heartbeat/reconnect/sticky-session complexity. Worth it only if v2 surfaces typing-indicators or read-receipts that need bidi. |
**Endpoint shape:**
```
GET /api/chat/stream
Accept: text/event-stream
[Last-Event-ID: <message_id>] ← optional for resume
```
Server emits:
```
event: message
id: <message_id>
data: {"type":"message_created","thread_id":"…","message":{…}}
event: message
id: <message_id>
data: {"type":"message_edited","thread_id":"…","message_id":"…","body":"…","edited_at":"…"}
event: message
id: <message_id>
data: {"type":"message_deleted","thread_id":"…","message_id":"…"}
event: read
data: {"type":"read_advanced","thread_id":"…","user_id":"…","up_to_message_id":"…"}
: ping ← every 25s, keeps Traefik from reaping idle stream
```
Server-side: per-user goroutine subscribed to `ChatBus` (see §4.2). On bus event, filter against the user's visibility (cached at connect; invalidate on team-membership change), encode SSE frame, flush. Connection close = unsubscribe.
**Failure modes + mitigations:**
| Failure | Mitigation |
|---|---|
| Idle proxy reaper (Traefik default ~3min) | Heartbeat comment every 25s. |
| Backpressure if recipient connection is slow | Per-user channel has a small buffer (16). Overflow drops the slow consumer's connection (client EventSource auto-reconnects with `Last-Event-ID`, replay catches up). |
| Multi-replica fanout (future) | Bus interface allows swap to `pgnotify.ChatBus` (LISTEN/NOTIFY on `paliad_chat`) without touching ChatService. |
| HTTP/1.1 6-conn-per-origin browser cap | Document. Single tab = no issue. Multi-tab is a known SSE constraint; users with many tabs will see one tab's stream go silent. Rare in legal-team usage; defer. |
**Disable response compression on this endpoint** in handler (`w.Header().Set("Content-Encoding", "identity")`) to prevent Traefik from buffering.
### 4.2 Message bus (interface)
**`internal/services/chat_bus.go`**:
```go
type ChatEvent struct {
Kind string // message_created | message_edited | message_deleted | read_advanced
ThreadID uuid.UUID
MessageID uuid.UUID // for message_* events
Payload map[string]any
AudienceFn func(uid uuid.UUID) bool // visibility filter — applied per subscriber
}
type ChatBus interface {
Publish(ctx context.Context, ev ChatEvent) error
Subscribe(ctx context.Context, userID uuid.UUID) (<-chan ChatEvent, func())
}
// Default: in-process. Per-user channel registry under sync.Map.
// Future: postgresChatBus uses pg_notify(channel="paliad_chat") for fanout.
```
**Why an interface from day 1?** paliad's deploy is single-replica today (docker-compose, one `web` container on Dokploy). When/if we scale to N, swap in the pg_notify implementation; no callsite changes. Cheap insurance.
### 4.3 Notification path (Q5)
**Recommendation: in-app sidebar badge ONLY in v1. Email digest deferred. PWA push deferred.**
| Channel | v1 | Phase 2 | Phase 3+ |
|---|---|---|---|
| In-app sidebar Chat unread badge | ✅ | | |
| Browser tab title flash on incoming message (foreground tab on chat surface) | ✅ (cheap) | | |
| `Notification` API (foreground, opt-in per browser permission) | ✅ (cheap) | | |
| Email digest of unread-since-last-login | | ✅ | |
| PWA push (background, requires VAPID + SW push handler) | | | ✅ |
| CalDAV alarm | ❌ | ❌ | ❌ Wrong channel — calendar is for time-anchored events. |
**Rationale for deferring PWA push:**
- paliad's `frontend/public/sw.js` is currently a 90-line cache-only worker. Adding push needs:
1. A new `addEventListener('push', …)` and `addEventListener('notificationclick', …)` block.
2. VAPID keypair generation + secure storage (env vars).
3. New table `paliad.push_subscriptions(user_id, endpoint, p256dh, auth, user_agent, created_at)`.
4. Server-side `web-push` Go lib (e.g. `github.com/SherClockHolmes/webpush-go`).
5. New endpoint `POST /api/push/subscribe` + permission-prompt UX.
- Worth ~600800 LoC and a separate review cycle. Don't bundle into chat MVP. Once chat usage is validated, push graduates as a Phase 2 task and serves chat + approvals + reminders together (one push pipeline, multiple producers).
**Rationale for deferring email digest:**
- Mail volume is already a friction point — t-paliad-064 just collapsed reminders into bundled digests. Layering an unread-chat email on top would re-saturate.
- Once usage shows it's needed, the digest can compose with the existing morning/evening slot reminder pipeline.
### 4.4 Read / unread + delivery state (Q6)
**Recommendation: per-(user, thread) last-read marker. No per-message read receipts.**
```sql
CREATE TABLE paliad.chat_reads (
user_id uuid REFERENCES paliad.users(id) ON DELETE CASCADE,
thread_id uuid REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
last_read_message_id uuid REFERENCES paliad.chat_messages(id) ON DELETE SET NULL,
last_read_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, thread_id)
);
```
**Unread count for sidebar badge:**
```sql
SELECT COUNT(*)
FROM paliad.chat_messages m
WHERE m.thread_id IN (<visible thread ids for caller>)
AND m.deleted_at IS NULL
AND m.author_id <> $caller
AND (
NOT EXISTS (SELECT 1 FROM paliad.chat_reads cr WHERE cr.user_id = $caller AND cr.thread_id = m.thread_id)
OR m.created_at > (SELECT cr.last_read_at FROM paliad.chat_reads cr WHERE cr.user_id = $caller AND cr.thread_id = m.thread_id)
);
```
Optionally cap at 99+ in UI.
**Why no per-message read receipts?** Privacy concern (legal team won't want "Anna saw your message 14 min ago, didn't reply"). UX clutter. Slack made the same call (workspace-default).
**`last_read_message_id` on `chat_reads`** is for "scroll to the boundary" UX — when you open a thread, the client scrolls to the message immediately above the marker and inserts a "neue Nachrichten" divider. The boundary is sticky until you mark-read again.
### 4.5 Message body (Q7)
**Recommendation: stored as Markdown source, rendered with a small whitelisted renderer.**
| Render | v1 | Notes |
|---|---|---|
| Bold (`**`/`__`) | ✅ | |
| Italic (`*`/`_`) | ✅ | |
| Inline code (` `` `) | ✅ | |
| Code block (```` ``` ````) | ✅ | Three-backtick fenced; preserves whitespace. |
| Bullet list | ✅ | |
| Numbered list | ✅ | |
| Blockquote (`>`) | ✅ | |
| Auto-link URLs | ✅ | `https?://` patterns auto-wrap as anchor with `target=_blank rel=noopener`. |
| Headings (`#`) | ❌ | Chat ≠ doc. Strip to plain text on render. |
| Images / embeds | ❌ | Use attachments (Phase 2). |
| Inline HTML | ❌ | Always sanitised out. |
| Raw URLs | ✅ | Auto-link them. |
**Library choice:** Server-side, use a small custom renderer or `github.com/yuin/goldmark` with a whitelist extension. Either works; I lean toward a tiny custom one (~150 LoC) because the subset is small and goldmark imports a lot. Frontend is render-only — server delivers HTML-rendered + raw source; client picks based on edit/view state.
**Storage:** raw Markdown source in `paliad.chat_messages.body`. Rendered HTML is computed on read (cheaply; cache in a separate column if benchmarks ever justify). Rendering on read keeps mention/entity-ref resolution dynamic (a deleted deadline's `#frist-…` chip degrades to a dimmed pill instead of a stale link).
### 4.6 Mentions + entity references (Q8)
**Recommendation: yes for v1 — `@user` + `#frist-…` + `#projekt-…` + `#termin-…` + `#approval-…`.**
**Resolution:**
- **Compose-side**: client-side autocomplete on `@` and `#`. Hits `/api/chat/autocomplete?q=…&context=<thread_id>` — server returns a small list scoped to the thread's visibility (mentions: thread members; entities: project's items + globally-visible items the caller can see).
- **Persist-side**: on POST, server parses tokens `@<slug>` / `#<entity>-<short_id>`, resolves to UUIDs, stores in `paliad.chat_mentions` (for users) and in `metadata.entity_refs` JSON (for entities). Original Markdown source preserves the `@anna` / `#frist-1234` syntax.
- **Render-side**: on read, server renders tokens as HTML with deep-links: `<a class="chat-mention" href="/team#user-<uuid>">@anna</a>`, `<a class="chat-entity-ref entity-frist" href="/deadlines/<id>">Frist 1234</a>`. Rendering re-checks visibility per recipient — invisible references render as dimmed `<span class="chat-entity-ref dimmed">[#frist-…]</span>`.
**Notification effect**: a mention drives a unread-count bump. A future Phase 2 enhancement: a separate "Erwähnungen" tab on `/chat` that filters to messages mentioning the caller, with a separate badge. v1 just lifts the unread-count visibility (mention or no mention, the badge ticks).
### 4.7 Attachments (Q9)
**Recommendation: out of scope for v1. Reference existing `paliad.documents` via `#dok-<id>` is a v2 pattern.**
Rationale:
- `paliad.documents` is the canonical document store with metadata (folder, tags, ACL planned). Adding a parallel attachment surface from chat would create two upload pipelines.
- v1 chat references existing documents via entity-ref `#dok-<id>` (deferred until v2 for the implementation; the syntax is reserved now).
- v2 attachment flow: drag-and-drop into chat → uploads into `paliad.documents` → message body gains a `#dok-<id>` reference. Single document, two surfaces.
### 4.8 Edits / deletions (Q10)
**Recommendation:**
- **Edit** allowed within 5 min of post. After 5 min, edit affordance is hidden — correct via reply.
- **Delete** allowed at any time by author. Soft-delete: `deleted_at` set, body replaced server-side with the rendered tombstone "Diese Nachricht wurde gelöscht." in the API payload (DE/EN per `users.lang`). Client renders the tombstone in muted styling. Mentions/entity-refs in deleted messages are preserved server-side (audit) but suppressed in render.
- **Admin override**: global_admin can delete any message at any time. Audit-marked: `metadata.deleted_by_admin = <admin_id>` and a system-message in the same thread "Admin hat eine Nachricht entfernt." (no body content disclosed).
**Why 5 min?**
- Long enough for typo undo, short enough to keep audit trust ("the message you just read isn't the one stored an hour later").
- Mirrors many legal-team chat tools (Slack's default-edit-window can be configured; Teams has 0 by default but admin can extend).
- Edit shows `(bearbeitet)` chip with tooltip showing `edited_at`.
**Why soft-delete only?** Compliance: paliad may need to demonstrate message provenance even after deletion. Soft-delete keeps the row + author + created_at; only `body` is hidden. Hard-delete is escalation-only (manual SQL by global_admin if legal forces).
### 4.9 Replies / threading (Q11)
**Recommendation: flat threads in v1. Slack-style sub-threads deferred.**
A flat thread means every message in the project chat lands at the bottom, ordered chronologically. To reply to a specific message, quote it (`>` Markdown blockquote) or @mention the author — same pattern Twitter/Mastodon use successfully without sub-threads.
**Why flat?**
- Sub-threads add: a `parent_message_id` column, a parent-thread-summary fold-out UI, "thread of threads" navigation, separate unread counts for thread vs sub-thread.
- For project-team chat (515 active members per project), flat is cleaner. Sub-threads pay off in larger channels (50+ members, parallel conversations).
- Re-introduce in v2 if usage shows specific demand for parallel parlay.
### 4.10 Search (Q12)
**Recommendation: thread-scoped search in v1. Cross-thread search deferred.**
- Thread-scoped: `WHERE thread_id = $1 AND body ILIKE '%' || $2 || '%' AND deleted_at IS NULL` — sub-second on threads up to ~10k messages. Above that, add a Postgres FTS index in v2.
- Cross-thread: would need `paliad.chat_messages` FTS + visibility join — workable but separable. Defer to Phase 2 once we know the cross-thread use case.
### 4.11 Storage schema (Q13)
**Migration 057:**
```sql
-- paliad.chat_threads ----------------------------------------------------------
CREATE TYPE paliad.chat_thread_kind AS ENUM ('project', 'dm');
CREATE TABLE paliad.chat_threads (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
kind paliad.chat_thread_kind NOT NULL,
project_id uuid REFERENCES paliad.projects(id) ON DELETE CASCADE,
-- DM 1:1 / small group: participants in chat_thread_participants;
-- project: visibility predicate (no rows in chat_thread_participants).
title text, -- DM small-group: optional user-supplied; project: NULL (use project name)
created_by uuid REFERENCES paliad.users(id),
created_at timestamptz NOT NULL DEFAULT now(),
last_activity timestamptz NOT NULL DEFAULT now(),
CONSTRAINT chat_thread_kind_consistency CHECK (
(kind = 'project' AND project_id IS NOT NULL) OR
(kind = 'dm' AND project_id IS NULL)
)
);
-- One project = one thread (idempotent provisioning).
CREATE UNIQUE INDEX chat_threads_project_idx ON paliad.chat_threads (project_id) WHERE kind = 'project';
CREATE INDEX chat_threads_activity_idx ON paliad.chat_threads (last_activity DESC);
-- paliad.chat_thread_participants ---------------------------------------------
CREATE TABLE paliad.chat_thread_participants (
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
joined_at timestamptz NOT NULL DEFAULT now(),
role text NOT NULL DEFAULT 'member' CHECK (role IN ('member', 'admin')),
-- 'admin' on a DM = the creator who can add/remove participants. Project chats have no rows here.
PRIMARY KEY (thread_id, user_id)
);
CREATE INDEX chat_thread_participants_user_idx ON paliad.chat_thread_participants (user_id);
-- paliad.chat_messages --------------------------------------------------------
CREATE TABLE paliad.chat_messages (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
author_id uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
-- author_id NULL = system message (auto-post)
body text NOT NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
-- metadata: { system: true, system_kind: "approval_requested", entity_refs: [...], deleted_by_admin: <uuid>, … }
edited_at timestamptz,
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX chat_messages_thread_idx ON paliad.chat_messages (thread_id, created_at DESC);
CREATE INDEX chat_messages_author_idx ON paliad.chat_messages (author_id, created_at DESC);
-- paliad.chat_reads -----------------------------------------------------------
CREATE TABLE paliad.chat_reads (
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
thread_id uuid NOT NULL REFERENCES paliad.chat_threads(id) ON DELETE CASCADE,
last_read_message_id uuid REFERENCES paliad.chat_messages(id) ON DELETE SET NULL,
last_read_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (user_id, thread_id)
);
-- paliad.chat_mentions --------------------------------------------------------
CREATE TABLE paliad.chat_mentions (
message_id uuid NOT NULL REFERENCES paliad.chat_messages(id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
PRIMARY KEY (message_id, user_id)
);
CREATE INDEX chat_mentions_user_idx ON paliad.chat_mentions (user_id);
-- paliad.project_teams.chat_access -------------------------------------------
ALTER TABLE paliad.project_teams
ADD COLUMN chat_access boolean NOT NULL DEFAULT true;
UPDATE paliad.project_teams SET chat_access = false
WHERE role IN ('local_counsel', 'expert');
-- RLS ------------------------------------------------------------------------
ALTER TABLE paliad.chat_threads ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_thread_participants ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_messages ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_reads ENABLE ROW LEVEL SECURITY;
ALTER TABLE paliad.chat_mentions ENABLE ROW LEVEL SECURITY;
-- Service-role bypasses RLS (paliad's pgx pool runs as service-role per t-paliad-088 lesson #2).
-- Policies exist for any future direct-DB query path; service-layer is the load-bearing gate.
-- (RLS predicates omitted from this design doc; sketch in §13 of impl plan.)
```
**`chat_threads.kind = 'dm'` uniqueness for 1:1**: enforce via service layer (sort participant UUIDs, check existing thread with exact participant set). Not in the schema CHECK because participants live in another table.
### 4.12 Retention (Q14)
**Recommendation: forever in v1. Soft-delete only. Phase 2 = export/archive flow.**
Compliance: HLC may need permanent archival of project-related conversations. Forever-storage is the safest default; cheaper than implementing rolling-window deletion + getting it wrong.
`deleted_at` is soft-delete by user/admin action; no time-based purge in v1.
**Phase 2** features for retention/compliance:
- Export thread to PDF/JSON (audit trail).
- Per-project retention override (e.g. Case closed → archive after 6 months).
- Search across archived (read-only) threads.
### 4.13 Audit / Verlauf integration (Q15)
**Recommendation: chat does NOT appear in Verlauf by default. Optional "Pin to Verlauf" affordance on individual messages → creates a `note` (Phase 2).**
Rationale:
- Verlauf already has 18 distinct `event_type` values (`deadline_*`, `appointment_*`, `checklist_*`, `note_*`, `project_type_changed`). Adding `chat_message_*` events for every chat post would dilute signal — Verlauf should answer "what changed on this matter" not "what was said".
- The "Pin to Verlauf" affordance lets users explicitly promote a chat message to a `paliad.note` (and emit a `note_created` event_type — the existing pattern). Phase 2; reserve the UX hook now.
### 4.14 PWA push (Q21)
**Recommendation: defer to Phase 2.** See §4.3 above for the cost/value reasoning. v1 ships without push; users get unread badge + tab-flash + foreground `Notification` API.
---
## §5 Sub-design C — Integration with existing surfaces
Answers Q16Q18.
### 5.1 Sidebar entry (Q16)
**Recommendation: BOTH — a top-level `Chat` sidebar entry with global unread badge AND a per-project `Chat` tab on `/projects/{id}` deep-linking the same thread.**
**Sidebar:**
```
Übersicht
├─ Home
├─ Dashboard
├─ Agenda
├─ Inbox (bell badge — approvals)
├─ Chat (new — chat badge — unread messages) ← new
└─ Team
Arbeit
└─ …
Meine Sichten
└─ …
```
Position: directly under `Inbox`. Same group ("Übersicht"). Same badge pattern (`id="sidebar-chat-badge"`).
**`/chat` page** (new top-level):
- Two-pane layout: thread list left (recently-active first), active thread right (messages + composer).
- Thread list shows: project chats the user has access to (sorted by `last_activity DESC`), DMs (sorted by `last_activity DESC`).
- Tabs: `Alle` (default), `Projekte`, `DMs`, `Erwähnungen` (Phase 2). Visual style mirrors `/inbox` tab chips.
**Per-project Chat tab on `/projects/{id}`:**
- Adds a new "Chat" tab next to existing tabs (Übersicht / Fristen / Termine / Verlauf / Team / …). Tab opens the same project thread, full-width-in-tab.
- Deep-links: `/projects/<id>?tab=chat` and `/projects/<id>/chat` (server resolves both).
**Mobile (BottomNav)**:
- BottomNav slots are full at 5 (Start / Projekte / + / Agenda / Menü). Don't swap a slot — chat surfaces from `Menü` and from per-project `Chat` tab. Defer dedicated mobile slot to Phase 2 once usage justifies.
### 5.2 Custom Views (#5) integration (Q17)
**Recommendation: chat messages are NOT a 5th source in the ViewService union.**
Rationale:
- ViewService unions four kinds: deadline, appointment, project_event, approval_request. Each is a *time-anchored event* with a structured semantics ("Frist X due on Y"). Chat messages are conversation, not events.
- Adding a `chat_message` source would dilute the substrate's purpose and produce noisy Custom Views ("show me everything in the next 30 days" → 80% chat noise).
- **Mentions and entity-refs do NOT cross over either.** A `#frist-1234` reference inside chat doesn't promote the chat message into the deadline's audit; the reference is a navigation aid, not an audit fact.
**Phase 2 escape hatch**: if demand emerges for "show me activity (chat + events) on this project", introduce a new `chat_activity` synthesised source that emits one row per *day-bucket-with-N-messages*, not per-message. That keeps Custom Views unflooded while exposing the "this project has been busy" signal. Reserve the source name; don't implement v1.
### 5.3 Bulk team email (#7) overlap (Q18)
**Recommendation: distinct surfaces, deliberate split.**
| Use case | Bulk email (#7) | Chat |
|---|---|---|
| Team-wide announcement, no reply expected ("Server-Wartung am Montag 18 Uhr") | ✅ | — |
| Coordination on a specific matter ("Anna, kannst du auf meine Frist 16.05. drauf schauen?") | — | ✅ |
| Process reminders / quarterly newsletters | ✅ | — |
| "Wer sieht heute den 14:00 hearing-call?" | — | ✅ |
| External-counsel briefing | ✅ (mail) | — (chat is internal-only by default) |
| Hot-fix coordination during litigation prep | — | ✅ |
| Birthday / kudos (if product wants it) | — | ✅ |
**Pattern:**
- **Email** is broadcast, archive-friendly, no expectation of synchronous reply, lives in user's regular inbox alongside client mail.
- **Chat** is back-and-forth, ambient, threaded, scoped to a project's team or a small DM group.
The two coexist; users self-select. No automatic cross-posting. If a user writes a chat post that is "really" a broadcast announcement, that's a soft heuristic the product can teach later (Phase 3 nudge: "Looks like a broadcast — send as email instead?").
---
## §6 Inventor follow-up questions for m
Beyond the 21 questions in the issue body, my design surfaced a few I cannot lock without a call:
| # | Question | Recommendation |
|---|---|---|
| Q22 | **DM creation policy**: anyone-to-anyone, or scoped to "people I share a project with"? | Recommend: scoped to "people I share at least one visible project with" — keeps DMs inside the matter graph, prevents random cross-firm pings. global_admin always reachable. |
| Q23 | **DM small-group cap**: hard limit on DM participants? | Recommend cap at **8** (informal coordination tier; above that, a project chat or partner-unit room is right). Not a hard schema cap; service-layer + UI cap. Lift later if we see demand. |
| Q24 | **Project chat auto-provision timing**: lazily on first read, or eagerly on project create? | Recommend lazy. Most projects never get chatter; lazy provisioning saves rows + noise on `/projects` list. Once demand is shown, switch to eager (one-line change). |
| Q25 | **System auto-post audience**: project chat post is visible to ALL thread members, including external counsel + observer. Is approval-request system-post leaking signal that external counsel shouldn't see? | Recommend: respect existing `chat_access` flag — observer reads, external counsel reads only if their `chat_access=true`. The same predicate as any chat post; the system just authors instead of a user. |
| Q26 | **Edit window length**: 5 min as recommended, or shorter (1 min) / longer (15 min) / no edit at all? | Recommend 5 min. 1 min is too short for "wait did I tag the right person", 15 min is long enough for someone else to have read+replied based on the original. |
| Q27 | **Markdown subset**: include or exclude blockquote? Tables? Strikethrough? | Recommend: blockquote ✅ (quoting prior message is the flat-thread alternative to sub-threading). Tables ❌ (unusual in chat, complicates renderer). Strikethrough ❌ (chat ≠ doc; rare and ambiguous). |
| Q28 | **`@everyone` / `@team`**: support a "ping the whole project team" mention? | Recommend: NO in v1. Spam risk. Lead can post a normal message; team members on the thread already see it. Phase 2: optional `@team` for project leads only, with confirmation prompt. |
| Q29 | **Chat unread badge: count messages or count threads?** | Recommend: count *messages* with caps at 99+. Threads-with-unread is easier on the eye but obscures volume; messages match user mental model of "you have N things to read". |
| Q30 | **Sound on incoming message** (foreground tab)? | Recommend: NO by default. Opt-in setting (`users.chat_sound_enabled`) deferred to Phase 2. Lawyers in court rooms with their phone open is a real failure mode. |
| Q31 | **Default landing on `/chat`**: most-recently-used thread, "Alle" thread list, or empty placeholder? | Recommend: most-recently-used thread (mirrors `/views` MRU pattern from t-144). First-time users land on an empty-state "Wähle einen Thread links". |
| Q32 | **Chat post triggers `last_activity` bump** on associated project (drives sidebar sort, dashboard "recent activity") — yes/no? | Recommend: yes for chat threads themselves (sort thread list). NO for `paliad.projects.last_modified` (chat shouldn't ride sibling sort signals — that's reserved for case-substantive changes). |
m's go on these locks the design. If any answer flips, I rev the doc before handing to the implementer.
---
## §7 Trade-offs and risks
### 7.1 Adoption risk (the elephant in the room)
**The biggest risk is not technical — it's whether teams actually use this.** HLC colleagues already have:
- WhatsApp + Telegram for fast informal coordination.
- Microsoft Teams / Outlook chat for firm-internal IM (assumption — verify).
- Email for formal asynchronous comms.
paliad chat would need to attract `"Anna, kannst du auf meine Frist 16.05. drauf schauen?"` away from those tools. The differentiator m cited in the issue is compliance + context-rich (auto-resolve `#frist-1234`, team set is pre-derived). That's plausible — but the cost of building it is real, and if PA colleagues stick to WhatsApp, paliad chat becomes a half-empty room that signals "this product doesn't know its users".
**Recommendation before implementation:** m asks two PA colleagues from different offices ("would you actually use this if it existed?", "what would make you switch from WhatsApp?"). Either keeps you honest or surfaces feature gaps the design doesn't cover.
If adoption looks weak, alternative scopes worth considering:
- **A: ship project chat only (no DM).** Project context is the real differentiator; DM is what WhatsApp does well. Less surface, less work, less risk of half-empty.
- **B: ship `@mention + reply` as a comment thread on each deadline/termin first** — closer to the Verlauf pattern, lower lift, and validates the idea before the full chat surface.
### 7.2 Single-replica SSE constraint
Today's docker-compose is one `web` container. SSE works fine. If we ever scale (multi-Dokploy-replica, blue-green deploy with overlap), in-process bus drops cross-replica messages.
**Mitigation:** abstract `ChatBus` interface from day 1. Future `pgnotify.ChatBus` implementation is ~80 LoC and a one-line wiring change. Document this in `internal/services/chat_bus.go`.
### 7.3 Observer-write semantics
`observer` role is read-only for chat per recommendation. There's a UX edge case: an observer who *thinks* they're a regular member (because everyone else is chatting) and gets a write-disabled composer. Mitigate with a clear empty composer hint: "Du bist Beobachter:in für dieses Projekt — Lesezugriff nur." Same pattern as observer's read-only Frist edit.
### 7.4 External counsel default OFF chat
Defaulting `chat_access=false` for `local_counsel`/`expert` is the right compliance default but creates onboarding friction: the first time external counsel is added to a project, the lead has to explicitly toggle them in. **Mitigate** with a one-time hint in the team-add modal: "Externe Anwält:in/Sachverständige:r — Chat ist standardmäßig deaktiviert. Aktivieren?".
### 7.5 Markdown sanitisation correctness
Hand-rolling a small Markdown subset risks XSS through subtle edge cases (`[click](javascript:…)`, malformed image URI, etc.).
**Mitigate:**
- Escape all rendered text first, then apply whitelisted Markdown tokens.
- For URLs: validate `https?://` prefix with stdlib `url.Parse`; reject everything else.
- Add a render test suite with known-bad payloads (data URIs, javascript: URIs, broken closures).
- If we end up importing goldmark anyway, lean on its strict mode + a custom rendering walker.
### 7.6 Chat as a Verlauf-substitute
Risk that users start treating chat as the audit log ("I told Anna in chat to extend that deadline"). Verlauf is the audit; chat is conversation. Mitigate by:
- The Phase 2 "Pin to Verlauf" affordance promotes specific chat messages to notes.
- UX copy on the chat composer: "Notizen am Vorgang? → Verlauf." (small hint, not a wall).
### 7.7 Mobile keyboard + composer + bottom-nav
Mobile keyboards on iOS Safari overlap fixed bottom-nav elements. The composer needs to play nicely with that — anchor at viewport-bottom but adjust on focus. Standard pattern (the existing checklist comment composer probably has the same issue solved). Worth a quick check in implementation, not a design blocker.
### 7.8 chat_messages explosion
Multiplied across all paliad projects, chat could grow to millions of rows over years. Indexes on `(thread_id, created_at DESC)` keep reads fast. PG handles 10M+ rows with ease at this index shape. Storage cost is negligible. Document the size projection in the impl plan but don't pre-optimize.
---
## §8 Phasing
**Phase 1 (chat MVP — bundled v1, single PR):** ~35004500 LoC
1. Migration 057 (chat schema + `project_teams.chat_access`).
2. `ChatService` + `ChatBus` interface + in-process implementation.
3. HTTP endpoints (8 in §10).
4. SSE stream endpoint with heartbeat + Last-Event-ID resume.
5. `frontend/src/chat.tsx` + client `client/chat.ts` + Markdown renderer.
6. `frontend/src/components/Sidebar.tsx` updated with Chat entry + badge.
7. Per-project Chat tab on `/projects/{id}`.
8. Approval auto-post wiring in `ApprovalService.Submit*`.
9. ~80 i18n keys DE+EN.
10. CSS for chat shell + bubbles + mention chips + composer.
**Phase 2** (~2000 LoC each, separate PRs as demand justifies):
- Email digest of unread chats (composes with reminder pipeline).
- PWA push notifications (VAPID + SW push handler + subscription endpoint).
- File attachments (chat → `paliad.documents`).
- Cross-thread search (FTS index + global search).
- "Pin to Verlauf" affordance.
**Phase 3+** (defer until Phase 1+2 usage validates):
- Per-deadline / per-termin micro-threads.
- Partner-unit rooms.
- Reactions, sub-threads, `@team`, sound/Notification config UI.
- Topical/cross-cutting rooms.
**Optional Phase 1 split (if implementer prefers):**
- 1A — Schema + `ChatService` + REST endpoints + project chat shell. No DMs, no SSE (polling stub for unread badge).
- 1B — DMs + SSE + mentions + entity-refs + approval auto-post.
If the implementer splits, they own the call. Both 1A+1B in a single PR is ~4500 LoC; each in its own PR is ~2000-2500. m can decide on the split when locking the design.
---
## §9 Implementer recommendation
**Recommended worker: noether (this worktree)** or a fresh coder.
Pattern-fluent Sonnet work; nothing here requires Opus-level architectural reasoning past this design. The substrate (visibility predicate, project_teams shape, SSE handling, sidebar/badge pattern, ViewService precedent) is well-trodden — implementation is mostly composition.
NOT cronus per memory directive (cronus retired from paliad).
Expected files (Phase 1):
- `internal/db/migrations/057_chat.{up,down}.sql`
- `internal/services/chat_service.go`
- `internal/services/chat_bus.go`
- `internal/services/markdown.go` (small renderer)
- `internal/handlers/chat.go`
- `internal/handlers/chat_stream.go` (SSE)
- `internal/handlers/handlers.go` (route wiring under `if svc.Chat != nil`)
- `internal/services/approval_service.go` (auto-post hook on Submit*)
- `cmd/server/main.go` (`chatBus := services.NewInProcessChatBus(); chatSvc := services.NewChatService(pool, …, chatBus)`)
- `frontend/src/chat.tsx` (page shell)
- `frontend/src/projects-detail.tsx` (Chat tab integration)
- `frontend/src/client/chat.ts` (orchestration, EventSource, autocomplete, edit/delete, read marker)
- `frontend/src/client/markdown.ts` (render-side companion if any)
- `frontend/src/client/sidebar.ts` (badge + unread-count fetch)
- `frontend/src/components/Sidebar.tsx` (new Chat entry)
- `frontend/src/styles/global.css` (chat-shell + chat-bubble + chat-mention + chat-composer styles)
- `frontend/src/i18n.ts` (~80 keys DE+EN)
- `frontend/src/build.ts` (chat.html bundle)
---
## §10 HTTP endpoints
| Method | Path | Purpose |
|---|---|---|
| GET | `/chat` | Chat shell page |
| GET | `/chat/dm/<thread_id>` | Deep-link to specific DM thread (server resolves visibility, redirects to /chat with state) |
| GET | `/api/chat/threads` | List threads visible to caller (project + DM), sorted by last_activity. Includes per-thread unread count. |
| POST | `/api/chat/dm` | Body `{ "participant_ids": [...], "title": "..." }`. Returns thread (idempotent for 1:1 by sorted participant set). |
| GET | `/api/chat/threads/<id>/messages?before=<msg_id>&limit=50` | List messages with cursor pagination. Returns rendered HTML + raw source per message. |
| POST | `/api/chat/threads/<id>/messages` | Post message. Body `{ "body": "..." }`. Server parses mentions/refs, inserts, publishes bus event. |
| PATCH | `/api/chat/messages/<id>` | Edit (5min author window). Body `{ "body": "..." }`. |
| DELETE | `/api/chat/messages/<id>` | Soft-delete (author or admin). |
| POST | `/api/chat/threads/<id>/read` | Body `{ "up_to_message_id": "..." }`. Updates `chat_reads`. |
| GET | `/api/chat/unread-count` | Sidebar badge. Returns `{ "total": N, "by_thread": {...} }`. |
| GET | `/api/chat/autocomplete?q=&context=<thread_id>` | Server-resolved mention/entity-ref autocomplete. |
| GET | `/api/chat/stream` | SSE long-lived; returns events filtered to caller's visibility. |
---
## §11 Frontend shape (Phase 1)
```
/chat [/chat]
┌───────────────────┬──────────────────────────────────────┐
│ THREADS │ Siemens AG · Litigation UPC München │
├───────────────────┼──────────────────────────────────────┤
│ Alle | Proj | DM │ ┌──────────────────────────────────┐ │
│ │ │ Anna · 14:23 │ │
│ ▶ Siemens AG · L. │ │ Hat jemand auf Frist #frist-1234 │ │
│ 3 ungelesen │ │ drauf geschaut? Replik bis Mo. │ │
│ ▷ EP1234567 │ └──────────────────────────────────┘ │
│ ▷ DM mit Anna │ ┌──────────────────────────────────┐ │
│ ▷ DM (3) UPC-Team │ │ ── neue Nachrichten ────────────│ │
│ │ ├──────────────────────────────────┤ │
│ │ │ Lukas · 14:30 │ │
│ │ │ Schau gleich rein, ist das EAU? │ │
│ │ └──────────────────────────────────┘ │
│ │ │
│ ├──────────────────────────────────────┤
│ │ ┌──────────────────────────────────┐ │
│ │ │ @anna danke! … ▶│ │
│ │ └──────────────────────────────────┘ │
└───────────────────┴──────────────────────────────────────┘
```
On mobile: thread list is a full page, tap → message page.
On `/projects/{id}?tab=chat`: messages pane only (thread list hidden), with project header above.
System-post visual:
```
┌───────────────────────────────────────────────┐
│ 🔔 Anna hat Genehmigung angefordert: │
│ Frist 16.05. (Replik einreichen) │
│ [ Zur Genehmigung → ] │
└───────────────────────────────────────────────┘
```
(Distinct background, no edit/delete affordance, deep-link button.)
---
## §12 Concrete recommendation summary
| # | Question | Recommendation |
|---|---|---|
| Q1 | Surface set v1 | Per-project + DMs |
| Q2 | Hierarchy visibility | Per-thread, predicate = `can_see_project` |
| Q3 | Approval cross-cut | System auto-post, no replacement |
| Q4 | Real-time arch | SSE |
| Q5 | Notification path | In-app badge + tab-flash + Notification API; defer push + email digest |
| Q6 | Read/unread | Per-(user,thread) last-read marker; no per-message receipts |
| Q7 | Body format | Markdown subset (no headings, no images) |
| Q8 | Mentions + refs | `@user`, `#frist-…`, `#projekt-…`, `#termin-…`, `#approval-…` |
| Q9 | Attachments | Defer Phase 2 |
| Q10 | Edits / deletes | Edit ≤5 min author; soft-delete author or admin |
| Q11 | Threading | Flat |
| Q12 | Search | Thread-scoped LIKE; defer cross-thread |
| Q13 | Schema | Migration 057: 5 new tables + `project_teams.chat_access` |
| Q14 | Retention | Forever, soft-delete only |
| Q15 | Verlauf | No; "Pin to Verlauf" Phase 2 |
| Q16 | Sidebar entry | Both — top-level Chat + per-project tab |
| Q17 | Custom Views | NOT a 5th source |
| Q18 | Bulk email overlap | Distinct surfaces |
| Q19 | Who can chat | Read = visibility; observer read-only; external opt-in via `chat_access` |
| Q20 | External counsel/expert | Default `chat_access=false`; lead toggles per project |
| Q21 | PWA push | Defer Phase 2 |
| Q22 | DM reachability | Scoped to "shares ≥1 visible project" |
| Q23 | DM small-group cap | 8 |
| Q24 | Auto-provision | Lazy on first read |
| Q25 | System-post audience | Same as any chat post (respects `chat_access`) |
| Q26 | Edit window | 5 min |
| Q27 | Markdown subset | Blockquote yes; tables no; strikethrough no |
| Q28 | `@everyone` / `@team` | No in v1 |
| Q29 | Badge count | Messages, capped at 99+ |
| Q30 | Sound | No (opt-in deferred) |
| Q31 | Default landing | Most-recently-used thread |
| Q32 | last_activity bump | Yes on chat thread; no on project record |
---
## §13 Open follow-ups (not for v1)
- **Bot integrations** (e.g. /Frist-Bot for natural-language deadline lookup). Out of scope; if AI chat (`feature-roadmap.md`) ever ships, it lives at `/ask` not `/chat`. Reserve mental separation.
- **External-firm participants** (opposing counsel, expert witnesses outside HLC). Big compliance question; not v1 / not v2 / not yet.
- **Slack / Teams bridging**. Very tempting and very complex (auth, identity mapping, message format translation). Defer until paliad chat usage justifies.
- **Voice messages** (German lawyers love voice notes). Out of scope.
---
## §14 What I need from m to lock
1. **§7.1 adoption sanity-check**: are PAs likely to use this, or is it a half-empty surface?
2. **Q1 — surface set**: confirm per-project + DMs, defer per-deadline / per-termin / partner-unit / topical.
3. **Q4 — SSE**: confirm SSE direction.
4. **Q5 — notification path**: confirm in-app-only v1 (push + email digest deferred).
5. **Q19/Q20 — chat_access flag** on `project_teams`, defaulting OFF for `local_counsel`/`expert`.
6. **Q22Q32 — the 11 follow-up questions** in §6.
7. **§8 phasing** — single PR or 1A+1B split.
If m greenlights with "I agree with all your recommendations - go." (the Q4 of t-139 pattern), I lock the design and the head routes the coder shift.
If m flips any answer, I rev the doc before handover.
**Inventor parks here.** No coder self-load.
---
## §15 Appendix — file/index inventory
For the implementer's reference; verified live 2026-05-07.
**Existing tables touched:**
- `paliad.project_teams` — new column `chat_access`, backfill external roles.
- `paliad.projects` — read-only, source for `path` traversal.
- `paliad.users` — read-only, FK target.
- `paliad.partner_unit_members`, `paliad.project_partner_units` — read-only, derivation predicate.
**New tables:**
- `paliad.chat_threads`
- `paliad.chat_thread_participants`
- `paliad.chat_messages`
- `paliad.chat_reads`
- `paliad.chat_mentions`
**Existing Go services touched:**
- `internal/services/visibility.go` — read-only reuse.
- `internal/services/derivation_service.go` — read-only reuse for partner-unit derivation check.
- `internal/services/approval_service.go` — auto-post hook on `Submit*`.
**New Go services:**
- `internal/services/chat_service.go`
- `internal/services/chat_bus.go` (interface + in-process default)
- `internal/services/markdown.go`
**Existing handlers touched:**
- `internal/handlers/handlers.go` — wire chat routes when `Chat != nil`.
**New handlers:**
- `internal/handlers/chat.go`
- `internal/handlers/chat_stream.go`
**Existing frontend touched:**
- `frontend/src/components/Sidebar.tsx`
- `frontend/src/projects-detail.tsx` (Chat tab)
- `frontend/src/client/sidebar.ts` (badge update)
- `frontend/src/i18n.ts` (~80 new keys)
- `frontend/src/build.ts` (chat bundle)
- `frontend/src/styles/global.css`
**New frontend:**
- `frontend/src/chat.tsx`
- `frontend/src/client/chat.ts`
- `frontend/src/client/markdown.ts` (or shared with views)
— end of design —