docs(ST-1108): document M11 question-channel — API + architecture + pattern

docs/API.md — twee nieuwe secties:
- 'Notifications' met /api/realtime/notifications SSE-endpoint (event-shapes,
  filter-rules, voorbeeld)
- 'Cron — Expire questions' met /api/cron/expire-questions (Bearer-auth,
  schedule, response-shape, manual curl)

docs/scrum4me-architecture.md — nieuw hoofdstuk 'Vraag-antwoord-kanaal Claude
↔ user' tussen QR-pairing-flow en Projectstructuur:
- Mermaid sequence-diagram (Claude → DB → trigger → SSE → user → answer →
  trigger → Claude polls)
- Threat-model-tabel (race, demo-misbruik, cross-product leak, cron-misbruik,
  growth, log-leakage)
- Subsectie 'Waarom hergebruik scrum4me_changes-kanaal' met trade-off vs M10's
  eigen-kanaal-aanpak

docs/patterns/claude-question-channel.md — herbruikbaar pattern 'Bidirectionele
async-comms tussen MCP-agent en interactieve user' met de vier eindpunten,
vier security-uitgangspunten, channel-strategie-tabel, TTL-richtlijn, en
sjabloon-bestanden per laag (DB / server / client / MCP-tools).

CLAUDE.md — extra rij in Implementatiepatronen-tabel die naar het nieuwe
pattern-doc verwijst.

Acceptatie 6 scenario's:
1. Sync happy path (MCP wait_seconds + UI submit) — handmatig getest tijdens
   ST-1105 acceptance-loop met de q-test injection
2. Async happy path — gedekt door get_question_answer-tool in ST-1102 +
   list_open_questions
3. Demo-block — actions/questions.test.ts (case 2: demo-user) + AnswerModal
   tooltip (visueel)
4. Access-isolation — notifications-stream.test.ts (case 'access-isolation')
5. Expiry — cron-expire-questions.test.ts (case '200 met juiste secret')
6. Race — actions/questions.test.ts (case 'al-answered' via atomic updateMany)

Quality gates: lint 0 errors, tsc clean, vitest 151/151 (19 files), npm run
build groen.

M11 is hiermee feature-compleet. feat/M11-claude-questions heeft 12 commits
lokaal, klaar voor user-acceptatie en PR.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Janpeter Visser 2026-04-28 01:56:03 +02:00
parent eeed5d7506
commit e5819ee079
4 changed files with 282 additions and 0 deletions

View file

@ -99,6 +99,7 @@ Lees het relevante patroon vóór je begint. Nooit uit het hoofd schrijven.
| Float sort_order drag-and-drop | `docs/patterns/sort-order.md` |
| Middleware (route protection) | `docs/patterns/middleware.md` |
| QR-pairing (unauth-SSE + pre-auth cookie) | `docs/patterns/qr-login.md` |
| Bidirectionele async-comms MCP-agent ↔ user | `docs/patterns/claude-question-channel.md` |
| Status-enum mapping (DB ↔ API) | `lib/task-status.ts` |
| Client/server module-boundary | `*-server.ts` bevat DB-calls of node-only deps; `*.ts` is pure (client-safe). Nooit `import { ... } from '@/lib/foo-server'` in een client-component, anders krijg je `Module not found: 'dns'`/`'pg'`-style runtime fouten |

View file

@ -417,6 +417,73 @@ curl -i -X POST -b /tmp/jar -c /tmp/jar \
---
## Notifications — Vraag-antwoord-kanaal (M11)
Endpoints voor de Claude vraag-antwoord-flow. De **MCP-tools** in de scrum4me-mcp-repo (`ask_user_question`, `get_question_answer`, `list_open_questions`, `cancel_question`) zijn de primaire schrijf-interface; de endpoints hieronder zijn voor de browser-UI en cron.
### `GET /api/realtime/notifications`
Server-Sent Events stream voor de notifications-bell in de NavBar. **User-scoped** — geen `product_id`-param; filtert server-side op alle producten waar de gebruiker eigenaar of teamlid is.
**Auth:** iron-session cookie. Demo-gebruikers mogen lezen.
**Response:** `text/event-stream`. Stream blijft open tot client sluit of server na 240s een hard-close doet (client herconnect).
**Events:**
- `event: state` — eenmalig direct na connect, met `{ questions: [...] }` als payload (zelfde shape als de live updates).
- `data: {...}` — bij elke status-overgang in `claude_questions`. Payload-shape:
```json
{
"op": "I" | "U",
"entity": "question",
"id": "cmoh...",
"product_id": "cmoh...",
"story_id": "cmoh...",
"task_id": "cmoh..." | null,
"assignee_id": "cmoh..." | null,
"status": "open" | "answered" | "cancelled" | "expired"
}
```
Het is een delta — voor de volledige vraag-tekst en options reconnect de client (initial-state-event levert ze opnieuw).
- `: heartbeat` — SSE-comment elke 25s.
**Server-side filter:**
- `payload.entity === 'question'` (`task` en `story` events horen op `/api/realtime/solo`)
- `payload.product_id` zit in de set producten met user-access (productAccessFilter)
**Voorbeeld:**
```js
const source = new EventSource('/api/realtime/notifications', { withCredentials: true })
```
---
## Cron — Expire questions
### `POST /api/cron/expire-questions`
Vercel cron handler die elke 6 uur draait. Markeert verlopen open vragen als `expired` en verlopen pending login_pairings als `cancelled`.
**Auth:** `Authorization: Bearer ${CRON_SECRET}` — header die Vercel automatisch injecteert wanneer de env-var op de project-omgeving staat. Zonder secret of bij mismatch: 401.
**Schedule:** `0 */6 * * *` (4× per dag).
**Response 200:**
```json
{
"expired_questions": 0,
"expired_pairings": 0,
"ran_at": "2026-04-28T00:00:00.000Z"
}
```
**Voorbeeld (handmatige trigger):**
```bash
curl -X POST -H "Authorization: Bearer $CRON_SECRET" \
https://your-app.vercel.app/api/cron/expire-questions
```
---
## Voorbeeldworkflow voor Claude Code
1. **Probe:** `GET /api/health?db=1` — bevestig dat de service en DB bereikbaar zijn.

View file

@ -0,0 +1,144 @@
# Patroon: Bidirectionele async-comms tussen MCP-agent en interactieve user
Het M11 vraag-antwoord-kanaal is herbruikbaar voor elke feature waarbij een
**autonome agent** (Claude Code via MCP, een Vercel-job, etc.) iets wil
ophelderen bij de **actieve gebruiker** zonder zelf te blokkeren of te raden.
> "Agent stelt vraag → vraag wacht persistent → user beantwoordt op een
> moment dat het hem uitkomt → agent leest het antwoord en gaat door."
Voorbeelden waar dit zou kunnen passen:
- AI-codereview: Claude vraagt om bevestiging op een controversiële refactor
- Background-import: queue-worker vraagt om een keuze (overschrijven/skippen)
per ambigue rij
- Multi-step migratie: scripted task pauseert op een handmatige bevestiging
- Approval-flow voor LLM-actions: user keurt een tool-call goed vóór 't echt
gebeurt
---
## Vier eindpunten
| Endpoint | Auth | Doel |
|---|---|---|
| MCP `ask_*` (write) | API-token + demo-blok | maakt rij in `*_questions`-tabel; optioneel `wait_seconds` voor sync polling |
| MCP `get_*_answer` (read) | API-token | leest huidige status + antwoord (voor latere session-pickup) |
| `POST /actions/answer` (Server Action) | iron-session + product-membership | atomic `updateMany WHERE status='open'` |
| `GET /api/realtime/<channel>` (SSE) | iron-session | user-scoped stream, filter op `entity` + product-access |
Plus:
- **Postgres-trigger** op de tabel die `pg_notify` doet op een gedeeld
channel met een `{op, entity, id, ...}`-payload
- **Cron-endpoint** dat verlopen rijen markeert (status='expired') zodat
achterstallige queries niet blijven groeien
---
## Vier security-uitgangspunten
1. **Atomic state-transities.** Antwoord-actie doet één UPDATE met alle
invarianten in de WHERE (status, expiry, owner-check). Concurrent dubbele
submit: PostgreSQL row-locking laat één caller count=1 zien, de rest 0.
2. **Demo-blok op writes.** Agent-side via `requireWriteAccess` (PERMISSION_
DENIED voor demo-tokens), user-side via early-return op `session.isDemo`
en disabled-submit-knop met tooltip in de UI.
3. **Access-isolation in SSE-filter.** Bij connect: query alle accessible
product-IDs voor deze user → Set. In notification-handler: drop alle
payloads waarvan `product_id ∉ accessibleSet`. Dit is naast de DB-query-
filter; redundant maar voorkomt lekkage als de DB-laag iets doorlaat dat
niet zou moeten.
4. **Geen gevoelige data in logs.** Payload bevat alleen IDs en status — de
tekst van vraag en antwoord komt via een aparte authenticated query.
`console.log` alleen `question_id`, nooit content.
---
## Channel-strategie: hergebruik vs. eigen kanaal
Twee opties bij meerdere realtime-features:
| Optie | Voordeel | Nadeel |
|---|---|---|
| **Eigen channel per feature** (M10 `scrum4me_pairing`) | Geen filter-leakage tussen features | 1 LISTEN-connectie per feature; meer DB-resources; meer routes |
| **Gedeeld channel met `entity`-key** (M11 `scrum4me_changes`) | 1 LISTEN per route; nieuwe entity = filter-aanpassing | Vergeten te filteren = leak |
Voor M11 is gekozen voor **hergebruik**: één channel scaalt beter naar v2
(comments, mentions, status-updates allemaal op zelfde stream) en de filter-
discipline is enforceable in code-review. Mitigatie voor leak-risico: expliciet
`if (payload.entity === 'X') return false` in elke route die feature X niet
hoort te zien — zoals `app/api/realtime/solo/route.ts` die `entity:'question'`
weert.
---
## TTL-richtlijn
- **Question lifetime**: 24 u — kort genoeg dat verlaten queries niet
ophopen, lang genoeg dat een gebruiker die afwezig is een nacht heeft om
te antwoorden
- **MCP-tool wait_seconds**: max 600 s — Claude wacht maximaal 10 min op een
antwoord; daarna `status: 'pending'` zodat hij later kan terugkomen via
`get_question_answer`
- **Cron schedule**: `0 */6 * * *` — 4× per dag genoeg om 'expired' op te
ruimen zonder Vercel-cron-budget te belasten
---
## Sjabloon-bestanden
Specifiek voor M11. Kopieer en pas aan:
### Database
- `prisma/schema.prisma`: model met `id`, `status`, `expires_at`, denormalized
`product_id` voor SSE-filter, asker/answerer-FKs, json `options?`-veld
voor multiple-choice
- `prisma/migrations/<ts>/migration.sql`: tabel-DDL + `notify_*_change()`-
functie + `AFTER INSERT/UPDATE`-trigger op gedeeld channel
### Server (Scrum4Me)
- `actions/questions.ts`: Server Action met getSession + Zod + demo-blok +
productAccessFilter + atomic updateMany
- `app/api/realtime/notifications/route.ts`: user-scoped SSE met
initial-state-event ná LISTEN actief (race-fix conform M10 ST-1004)
- `app/api/cron/expire-questions/route.ts`: Bearer-auth via CRON_SECRET +
updateMany WHERE expires_at<now
### Client (Scrum4Me)
- `stores/notifications-store.ts`: Zustand store met init/upsert/remove +
selectors voor count en for-you-count
- `lib/realtime/use-notifications-realtime.ts`: EventSource hook met
state/message-handlers + reconnect-backoff + Page Visibility pause
- `components/notifications/notifications-bell.tsx` + `notifications-sheet.tsx`
+ `answer-modal.tsx`: Bell met badge, slide-over met item-list, Dialog
met free-text/options-radio
### MCP-tools (scrum4me-mcp)
- `src/tools/ask-user-question.ts`: write-tool met optionele `wait_seconds`-
polling (intern setInterval tot status verandert of timeout)
- `src/tools/get-question-answer.ts`: read-tool voor latere session-pickup
- `src/tools/list-open-questions.ts`: read-tool voor session-start-check
- `src/tools/cancel-question.ts`: write-tool, asker-only via atomic
`updateMany WHERE asked_by + status='open'`
---
## Wanneer dit patroon NIET gebruiken
- Wanneer beide kanten al synchroon kunnen werken — dan is een gewone
fetch/Server-Action eenvoudiger
- Wanneer realtime niet kritiek is — een korte poll-loop is simpeler dan een
SSE-stream
- Wanneer er één centrale beslisser is — gebruik dan een gewone form-flow;
het patroon hier is voor situaties waar de agent **niet hoeft te wachten**
op één specifieke gebruiker
---
## Referenties
- Volledige flow + threat-model: `docs/scrum4me-architecture.md` § Vraag-
antwoord-kanaal Claude ↔ user
- Endpoint-contract: `docs/API.md` § Notifications + Cron
- LISTEN/NOTIFY-pattern: `app/api/realtime/solo/route.ts` (M8 ST-802) — zelfde
ReadableStream + heartbeat + hard-close + abort-cleanup
- M10 vs M11 keuze tussen eigen/gedeeld kanaal: zie threat-model-tabel

View file

@ -591,6 +591,76 @@ Dit patroon is herbruikbaar — zie `docs/patterns/qr-login.md`.
---
## Vraag-antwoord-kanaal Claude ↔ user (M11)
Persistent kanaal tussen Claude Code (via MCP) en de actieve Scrum4Me-gebruiker.
Wanneer Claude tijdens een implementatie vastloopt op een keuze, schrijft hij een
gestructureerde vraag naar `claude_questions`. Een Postgres-trigger emit op het
**bestaande** `scrum4me_changes`-kanaal (hergebruik uit M8) met `entity: 'question'`.
De Scrum4Me-app heeft een aparte user-scoped SSE-route die op dit kanaal abonneert,
filter't op product-toegang en de notifications-bell in de NavBar voedt. Iedere
gebruiker met product-membership kan antwoorden; story-assignee krijgt visuele
emphase. Claude leest het antwoord (sync via polling met `wait_seconds`, of in
een latere sessie via `get_question_answer`) en gaat door.
### Sequence
```mermaid
sequenceDiagram
participant C as Claude (MCP)
participant DB as Postgres
participant SC as scrum4me_changes channel
participant SSE as /api/realtime/notifications
participant U as Scrum4Me UI (browser)
C->>DB: INSERT claude_questions (status=open)
DB->>SC: pg_notify {entity:'question', op:'I', id, ...}
SC->>SSE: notification (filter: question + product-access)
SSE->>U: data event → Zustand store upsert → bell badge
Note over U: Gebruiker klikt bell → Sheet → Modal
U->>DB: answerQuestion(questionId, answer)<br/>Server Action: atomic updateMany WHERE status='open'
DB->>SC: pg_notify {entity:'question', op:'U', status:'answered'}
SC->>SSE: notification
SSE->>U: data event → store remove → bell badge -1
Note over C: Optioneel: ask_user_question(wait_seconds) polt elke 2s
C->>DB: SELECT status FROM claude_questions WHERE id=...
DB-->>C: status='answered', answer='...'
C->>C: gaat door met implementatie
```
### Threat-model
| Aanval | Mitigatie |
|---|---|
| **Race**: dubbele submit op zelfde vraag | Atomic `updateMany WHERE status='open'` — één caller ziet count=1, rest count=0 met disambiguatie via second findFirst |
| **Demo-account misbruik** | `requireWriteAccess` op MCP-write-tools (PERMISSION_DENIED), early-return op `session.isDemo` in answerQuestion Server Action, disabled submit + tooltip in AnswerModal |
| **Cross-product leak** | `productAccessFilter` op DB-query én SSE-server-side-filter (Set met user's accessible product-IDs) |
| **Cron-endpoint misbruik** | `Authorization: Bearer ${CRON_SECRET}` — Vercel injecteert automatisch; faalt 401 als secret niet gezet (geen open endpoint in dev) |
| **Onbeperkte vragen-groei** | `expires_at` 24 u + Vercel cron `0 */6 * * *` markeert `status='expired'` → uit notifications-bell |
| **Gevoelige info in logs** | Logging alleen `question_id`, nooit vraag- of antwoord-tekst |
### Waarom hergebruik scrum4me_changes-kanaal
In tegenstelling tot M10 (eigen `scrum4me_pairing`-kanaal) is M11 een uitbreiding van
de bestaande realtime-infra. Voordelen:
- Eén Postgres-NOTIFY-listener per route i.p.v. twee — minder DB-connecties
- Solo-realtime + notifications kunnen onafhankelijk evolueren via de `entity`-key
- Toekomstige entities (bijv. `entity: 'comment'`, `entity: 'mention'`) hoeven geen
nieuw kanaal — alleen een filter-aanpassing in de route die ze wil ontvangen
Risico: een nieuwe entity vergeten te filteren leidt tot lekkage. Mitigatie:
expliciet `if (payload.entity === 'X') return false` in elke SSE-route die
betrokken-features niet hoort te zien (zoals de solo-route die `entity:'question'`
weert).
Dit patroon (notification-channel via een bestaande pg_notify-stream) is
herbruikbaar — zie `docs/patterns/claude-question-channel.md`.
---
## Projectstructuur
```