19 KiB
spec, created, status
| spec | created | status |
|---|---|---|
| docs/specs/integrations/requirements/external-question-answering.md | 2026-08-17 | done |
Implementation Plan: External Question Answering
Overview
The deliverable is a durable, authorized, idempotent clarification resolution path; the two MCP
tools are thin wrappers over it. Order follows that dependency: the clarification_resolutions
claim table and its repository first, then the durable bundle read queries the list tool needs,
then the single ResolveBundle service operation, then the two callers (REST endpoints, MCP
tools), then the frontend and catalog, then E2E. Nothing publishes a resume event until the claim
row exists, so every intermediate state leaves the product working: after task 04 the existing
overlay is already fixed (double-answer, authorization), and tasks 05+ add the external surface on
top of a path that is already correct.
Backend
Schema — clarification_resolutions
apps/backend/internal/task/repository/sqlite/base_schema.go
- New const
clarificationResolutionsSchemaDDLand stepinitClarificationResolutionsSchema, appended to theinitSchema()step table afterr.initTaskReviewSchema:
CREATE TABLE IF NOT EXISTS clarification_resolutions (
pending_id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
session_id TEXT NOT NULL,
status TEXT NOT NULL,
response TEXT NOT NULL,
resume TEXT NOT NULL DEFAULT 'failed',
resolved_by TEXT NOT NULL DEFAULT '',
source TEXT NOT NULL,
resolved_at TIMESTAMP NOT NULL,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_clarification_resolutions_task
ON clarification_resolutions(task_id);
apps/backend/internal/task/repository/sqlite/base_migrations.go:migrateClarificationResolutions()applies the same DDL throughr.migrate.Apply, called fromrunMigrations(). The init step already covers existing databases (CREATE TABLE IF NOT EXISTSruns every boot), but the spec andapps/backend/AGENTS.mdboth require the logged, replay-tested migration entry; it is the path the replay test asserts against.ensureMessageMetadataIndexes(same file,base_schema.go:92) gains a third index so the bundle list query has an index-assisted pre-filter:idx_messages_clarification_status ON task_session_messages(type, (<JSONExtract metadata.status>)). Pre-filter only — membership is still the resolution-row check (spec L2/D4).
resume is a column the spec's data-model table omits. R8a requires a loser to receive the
value recorded for the winning resolution, which is only possible if it is persisted. The column
is written pessimistically at insert (failed for outcomes that publish, not_applicable for
cancel and R9 rejection) and upgraded to published after a successful publish, so a loser reading
between insert and update never sees a falsely optimistic value. Update the spec's table with this
column in the same change.
Model and repository
apps/backend/internal/task/models/clarification_resolution.go(new):ClarificationResolution, plusClarificationBundle/ClarificationBundleQuestionused by the read side.apps/backend/internal/task/repository/sqlite/clarification_resolution.go(new):InsertClarificationResolution(ctx, *models.ClarificationResolution) (claimed bool, existing *models.ClarificationResolution, err error)—INSERT ... ON CONFLICT (pending_id) DO NOTHING; when zero rows are affected, re-read and return the winner's row withclaimed=false. The primary-key conflict is the CAS (spec step 4).UpdateClarificationResolutionResume(ctx, pendingID, resume string) error.GetClarificationResolution(ctx, pendingID) (*models.ClarificationResolution, error).
apps/backend/internal/task/repository/sqlite/clarification_bundle.go(new):ListUnresolvedClarificationBundles(ctx, models.ClarificationBundleFilter) ([]*models.ClarificationBundle, error)—task_session_messageswheretype='clarification_request',LEFT JOIN tasks/LEFT JOIN workspaces,NOT EXISTS (SELECT 1 FROM clarification_resolutions ...)(D4),GROUP BY <JSONExtract metadata.pending_id>withMIN(created_at) AS bundle_created_at(D1),ORDER BY bundle_created_at ASC, pending_id ASC(L6), keyset cursor(bundle_created_at > ? OR (bundle_created_at = ? AND pending_id > ?))(D6),bundle_created_at >= ?forupdated_since(L8),LIMIT ?+1to derivenext_cursor(L9). Owner scoping is in the query, not a post-filter, so pagination stays correct:AND (t.workspace_id = '' OR w.owner_id = '' OR w.owner_id = ?)when scoped (D5).GetClarificationBundle(ctx, pendingID) (*models.ClarificationBundle, error)— the per-question rows plustask_id/session_id, used byResolveBundlestep 1.- Both build questions from message metadata by
question_indexascending (L5), degrading toquestion_idwith empty prompt/options whenquestionmetadata is unparseable (L15), and reporting each message's ownstatus, treating absent/unrecognized aspending(D3, L12).
apps/backend/internal/task/repository/interface.go: add the five methods to the message/task repository interface.
Both files use dialect.JSONExtract and r.ro.Rebind; every new dialect-sensitive method needs
the env-gated Postgres behavior test (KANDEV_TEST_POSTGRES_DSN) per apps/backend/AGENTS.md.
Service — ResolveBundle
apps/backend/internal/clarification/resolver.go (new). One operation, both surfaces:
type OutcomeKind string // "answered" | "rejected" | "cancelled"
type Outcome struct {
Kind OutcomeKind
Answers []Answer
RejectReason string
Source string // "web" | "mcp" | "internal"
}
type Resolution struct {
PendingID, TaskID, SessionID string
Status Status
Response *Response
Resume string // "published" | "failed" | "not_applicable"
ResolvedBy, Source string
ResolvedAt time.Time
}
func (r *Resolver) ResolveBundle(ctx context.Context, pendingID string, outcome Outcome) (*Resolution, bool, error)
Outcome.Kind replaces the spec's rejected bool because cancel joins the same claim (X1) and a
three-valued outcome is what the table's status column already stores; a bool plus a separate
cancel flag would let both be set.
Ordered steps, matching the spec exactly:
GetClarificationBundle→task_id/session_id; empty ⇒ErrBundleNotFound(A5).taskAuthorizer.AuthorizeTaskAccess(ctx, taskID); any error ⇒ErrBundleNotFound(A1–A3).- Validate against the bundle's question set (N6, N7, N8, N8a, N8b) — before the claim (N8c).
validateRespondAnswersmoves here fromhandlers.go:348and gains the option-ID check (N8) and the 2000-character caps (N8b) so both surfaces share one rule. InsertClarificationResolution. Conflict ⇒ return the stored row withclaimed=falseand perform none of step 5 (R2, R3, R6).- Winner only, in this order: per-question durable updates in
question_indexascending (R4, D2) → deliver to the in-memory waiter if present → publish. Any per-question update failure returnsErrPartiallyApplied, publishes nothing, and leaves the row in place (R5).
Event selection stays as today: live waiter ⇒ clarification.primary_answered (R7); no waiter ⇒
clarification.answered (R8); rejection with no waiter ⇒ clarification.stale_dismissed and no
resume (R9); cancel ⇒ clarification.cancelled, closing CancelCh when an entry exists (X3).
Publication success upgrades resume to published; failure leaves failed (R8a).
Dependencies are narrow interfaces declared in the package (bundleReader, resolutionStore,
taskAuthorizer, MessageCreator, EventBus, *Store) so internal/clarification does not
import task/service.
REST endpoints
apps/backend/internal/clarification/handlers.go
Handlersgainsresolver *Resolver;NewHandlers/RegisterRoutessignatures extend, andapps/backend/internal/backendapp/helpers.go:1135passes the resolver built fromp.taskRepo+p.taskSvc.AuthorizeTaskAccess.httpRespondcollapses to: bind →ResolveBundle→ map. Success is200 {"success":true,"claimed":bool,"status":string,"response":{...},"resume":string}.ErrBundleNotFound⇒404 {"error":"clarification request not found"}(A3 — same body as a fabricated ID, no task/session/workspace/question text).ErrValidation⇒ 400.ErrPartiallyApplied⇒ 500 naming the bundle (R5). The event-fallback branch (handlers.go:326-336), theErrAlreadyResponded409, andapplyAnswersToMessagesare deleted — their behavior now lives in the resolver.httpCancelRequestgoes throughResolveBundlewithKind: "cancelled"and no longer 404s when the in-memory entry is gone (X2).httpGetRequest/httpWaitForResponseauthorize before reading (A2), takingtask_idfrom the in-memoryRequest.TaskIDwhen present and from the durable messages otherwise; they keep their current 404-after-cleanup behavior otherwise.httpCreateRequestis untouched (A6).
MCP tools
apps/backend/pkg/websocket/actions.go:ActionMCPListPendingQuestions = "mcp.list_pending_questions",ActionMCPAnswerQuestion = "mcp.answer_question".apps/backend/internal/mcp/server/server.go: new group inprofileToolGroups()—{name: "external-questions", enabled: external, register: func(s *Server) { s.registerQuestionAnsweringTools() }}(S1, S2). No system-prompt change (S4);ask_user_question_kandevstays off external (S3).apps/backend/internal/mcp/server/question_handlers.go(new): tool schemas and the two handlers that forward vias.backend.RequestPayload, which carries the HTTP request context and therefore the caller's identity.apps/backend/internal/mcp/handlers/question_handlers.go(new):handleListPendingQuestions(repo query + cursor encode/decode, L13 rejects an unparseableupdated_sinceorcursor, L10 clampslimitto 1..200 default 50, L11 empty result is not an error) andhandleAnswerQuestion(delegates toResolveBundle,claimedin the payload, N3/N4/N5). Registered inhandlers.goalongside the othermcp.*actions and wired through a newSetClarificationResolversetter (the constructor already takes 13 parameters).- The gateway already refuses every
mcp.*action over the raw browser WebSocket (internal/gateway/websocket/client.go:174), which is why these actions need nopending_identry indispatch_scope.go. Add a pinning assertion so that guard cannot be removed silently.
list_tasks_kandev enrichment
apps/backend/internal/mcp/handlers/handlers.go:553 — after dto.FromTask, batch
taskSvc.BatchGetSessionsForTasks + taskSvc.GetPendingActionsForSessions and stamp
TaskPendingAction / PrimarySessionPendingAction using the same projection rules as
internal/task/handlers/task_http_handlers.go:382-417 (permission outranks clarification;
input-capable sessions only). Null when there is no blocked session (T2). Extract the shared
projection into internal/task/dto so the two surfaces cannot drift.
Frontend
apps/web/hooks/domains/session/use-clarification-group.ts
- Both
fetchcalls (postClarificationBatch,postClarificationSkip) sendcredentials: "include", matchinglib/api/client.ts:80(W1). - Both parse the response body and return
{ state, claimed }. Aclaimed: falsesuccess still closes the overlay (W2), but the caller skips the optimisticsafeApplyResolvedStatus(..., localAnswers)— the winner's answers are not this client's, and writing local answers into metadata would show the user an answer that was never recorded. The status still flips so the overlay does not strand onpending. - The
res.status === 409branch stays as a compatibility path; the backend no longer emits it.
API client
No new calls. The two MCP tools have no browser surface.
State
No store slice changes.
Tests
- Claim is exactly-once under concurrency (R3). Two goroutines calling
ResolveBundleon onepending_idagainst a shared real SQLite database; assert exactly oneclaimed=true, one row, one event published, and the loser receiving the winner's payload.apps/backend/internal/clarification/resolver_concurrency_test.go. Real DB, not a mocked store — the claim is a database constraint. - Claim survives restart (R6). Resolve, clear the in-memory store, resolve again; second call
returns
claimed=false.resolver_test.go. - Insert conflict returns the existing row. Table-driven unit test over
InsertClarificationResolution.sqlite/clarification_resolution_test.go, plus the env-gated Postgres variant and aschema_replay_test.gocase for the new table. - Bundle listing. Ordering (L6), cursor exclusivity and stability (L9, D6),
updated_sinceintersection with cursor (L14),limitclamping (L10), empty result (L11), mixed-status bundle with no resolution row is returned (L12), resolved bundle is excluded (D4), unparseable question metadata degrades rather than hides (L15),MIN(created_at)as bundle time (D1).sqlite/clarification_bundle_test.go. - Authorization (A1–A5). Table-driven over all four endpoints in both auth modes: enabled with
a foreign workspace ⇒ 404 with a body byte-identical to a fabricated ID and no task/session/
workspace/question text; disabled ⇒ unchanged behavior (A4 is the compatibility guarantee).
apps/backend/internal/clarification/handlers_authz_test.go. - Validation before claim (N8c). Each of N6/N7/N8/N8a/N8b rejected with no row written and the
bundle still answerable.
resolver_validation_test.go. - Partial apply (R5). Message-update failure on question 2 of 2 ⇒ error, no event, row present,
bundle not re-listed and not re-answerable.
resolver_test.go. - Resume reporting (R8a). Publish failure ⇒
resume:"failed"with the answer still recorded; cancel and stale-dismiss ⇒not_applicable; loser carries the winner's value. - Cancel joins the claim (X1–X3). Cancel with no in-memory entry succeeds; cancel racing an
answer observes R2;
CancelChcloses when an entry exists. - MCP tool surface.
internal/mcp/server/server_test.go:1017TestServerModeExternal_ToolCount35 → 37;internal/mcp/server/external_integration_test.go:65keeps itsNotContains "ask_user_question_kandev"and gains positive assertions for both new names; a new test asserts neither tool registers on kanban/office/configuration (S2).internal/mcp/handlers/handler_inventory_test.gomeasures the delta dynamically — no change. mcp.*stays off the browser WebSocket. Pinning test oninternal/gateway/websocket/client.go's prefix guard.list_tasks_kandevenrichment (T1, T2). Handler test asserting both fields for a blocked session and null for an unblocked one.- Web hook.
apps/web/hooks/domains/session/use-clarification-group.test.tsgains cases forcredentials: "include"on both POSTs and forclaimed:falseclosing the overlay without applying local answers. - Catalog.
apps/web/lib/settings/external-mcp-tools.test.tspinned count 30 → 37, drift note deleted, key-resolution test unchanged (C3).
E2E Tests
- Scenario: GIVEN a clarification bundle in chat, WHEN the user submits and a concurrent
answer has already won, THEN the overlay closes rather than stranding on
pending. File:apps/web/e2e/tests/chat/clarification.spec.ts(extend; do not add a spec file). What to verify: the existing**/api/v1/clarification/*/respondroute interception returns200 {"success":true,"claimed":false,"status":"answered","resume":"published"}; the carousel closes and the messages render resolved.
No E2E for the two MCP tools — they have no browser surface and are covered by Go tests at the MCP server and handler layers.
Verification Results
All nine task files' ## Results sections are the source of truth for exact commands and
outcomes/counts per task; this section is the cross-task summary.
- Backend gauntlet (this session, Wave 10):
go test ./internal/clarification/... ./internal/mcp/... ./internal/workflow/... ./internal/backendapp/... ./internal/task/repository/sqlite/... ./internal/task/... ./internal/gateway/websocket/...all pass.gofmt -lreports no files across every touched package.go build ./...succeeds.golangci-lint run ./internal/clarification/... ./internal/mcp/... --timeout=5mis clean.make -C apps/backend test(full suite) reported 3 failures, all ininternal/system/storage/workspaces(open dependency workspace root: not a directory) — a package this branch never touches (confirmed viagit log --oneline -1 --on that path) and a pre-existing macOSt.TempDir()symlink-resolution quirk (/var/foldersvs/private/var/folders), not a regression from this feature. - Frontend gauntlet (this session, Wave 10 + prior segments):
pnpm --filter @kandev/web test -- lib/settings/external-mcp-tools.test.ts hooks/domains/session/use-clarification-group.test.tspasses.pnpm run typecheck,pnpm run lint,pnpm run i18n:check, andpnpm run i18n:ratchetfromapps/webare all clean. - E2E gauntlet (this session, Wave 10):
pnpm run build:e2e && pnpm e2e:raw e2e/tests/chat/clarification.spec.tspasses all 31 cases.pnpm run e2e:sleep-ratchetis clean.pnpm run lint:e2e-sleepswith no path argument surfaces ~374 pre-existing, unrelated errors — a structural characteristic ofeslint.e2e-sleeps.config.mjs(a narrow config meant to be path-scoped); scoped to the one touched file it is clean. - Cleanup/teardown: only ephemeral
t.TempDir()test databases and the standard Playwright artifact directory; no persistent or external side effects from any task in this feature.
Implementation Waves And Parallel Candidates
Wave 1 (parallel candidates — user authorization required; disjoint files):
- [x] [task-01-resolutions-table](task-01-resolutions-table.md)
- [x] [task-06-list-tasks-pending-action](task-06-list-tasks-pending-action.md)
- [x] [task-08-external-tool-catalog](task-08-external-tool-catalog.md)
Wave 2:
- [x] [task-02-bundle-queries](task-02-bundle-queries.md)
Wave 3:
- [x] [task-03-resolve-bundle](task-03-resolve-bundle.md)
Wave 4:
- [x] [task-04-rest-endpoints](task-04-rest-endpoints.md)
Wave 5:
- [x] [task-05-mcp-question-tools](task-05-mcp-question-tools.md)
Wave 6:
- [x] [task-07-web-clarification-client](task-07-web-clarification-client.md)
Wave 7:
- [x] [task-09-e2e-duplicate-submit](task-09-e2e-duplicate-submit.md)
Tasks 04 and 05 both follow 03 and touch mostly disjoint trees, but both edit
apps/backend/internal/backendapp/helpers.go (route wiring at :1135, MCP handler wiring at :1535),
so they are sequential rather than parallel candidates. Task 08 is listed in wave 1 because its
catalog entries and locale keys are independent of the backend; its pinned count of 37 only becomes
true once task 05 lands, which is why task 05's checks include re-running the catalog test.
Open Questions
- The spec's
clarification_resolutionstable omits theresumecolumn that R8a's "loser carries the winner's recorded value" requires. This plan adds it and states the write ordering (pessimistic at insert, upgraded after publish). Update the spec's data-model table in the same change rather than leaving plan and spec disagreeing.