Scrum4Me/docs/patterns/claude-question-channel.md
Janpeter Visser e10f8f81bc
Phase 2 — Normalize file naming (#59)
* docs(naming): drop scrum4me- prefix from doc filenames

Rename 10 docs/scrum4me-*.md files to unprefixed kebab-case names.
Update every internal link in docs/, CLAUDE.md, AGENTS.md, README.md.

* docs(naming): lowercase API.md and MD3 filenames

Rename docs/API.md → docs/api.md and
docs/MD3_Color_Scheme_Documentation.md → docs/md3-color-scheme.md.
Update all internal links across 7 files.

* docs(naming): rename plan file to kebab-case ASCII

Rename "docs/plans/Tweede Claude Agent — Planning Agent.md"
→ docs/plans/tweede-claude-agent-planning.md. No external links needed updating.

* docs(naming): rename middleware.md to proxy.md (next 16)

docs/patterns/middleware.md → docs/patterns/proxy.md following
the Next.js 16 proxy.ts rename. Update link in CLAUDE.md.

* docs(naming): polish CLAUDE.md doc-index after renames

Fix doubled scrum4me-scrum4me-mcp repo references (cascade from
prior sed) in CLAUDE.md, docs/architecture.md, backlog.md,
agent-instruction-audit.md, and plans/ST-1109. Update
'Middleware' label to 'Proxy middleware' in patterns table.
2026-05-03 03:00:47 +02:00

145 lines
6.4 KiB
Markdown

# 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 4 * * *` — daily op een rustig tijdstip (Vercel Hobby
staat alleen daily crons toe; Pro ondersteunt fijnmaziger). 24 u TTL +
daily cleanup houdt de tabel klein zonder 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/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