18 KiB
status, system, requirements, created, owners
| status | system | requirements | created | owners | ||
|---|---|---|---|---|---|---|
| draft | integrations |
|
2026-08-16 |
|
External Question Answering — authorized, discoverable, idempotent clarification resolution System Design Part 1
Purpose and boundaries
This design preserves the technical source detail for REQ-INTEGRATIONS-EXTERNAL-QUESTION-ANSWERING-001 during migration.
Requirement mapping
| Requirement | Design section |
|---|---|
REQ-INTEGRATIONS-EXTERNAL-QUESTION-ANSWERING-001 |
Migrated source detail |
Migrated source detail
Revision note (2026-08-20, spec round 6). This spec was first written against
mainon which nothing made clarification resolution atomic. While it was in Build, upstream commit7e2c4ae84("fix: enforce active clarification lifecycle", #2669) merged its spec,docs/specs/tasks/requirements/clarification-active-lifecycle.md, which landed an atomic current-turn claim and a turn-supersession lifecycle. That work supersedes this spec's entire claim mechanism — theclarification_resolutionstable,ResolveBundle's own claim SQL, and the workflow-guard changes are all retired here rather than merged alongside it. What remains, and what this revision specifies, is the half upstream did not build: authorization, out-of-band discovery, idempotent replay for a losing caller, and the MCP surface. See Upstream baseline for what is already done, and Retired criteria for the identity of every rule this revision withdraws and why.
Why
When a Kandev agent calls ask_user_question_kandev, the resulting question is answerable in
exactly one place: a popup rendered over the chat input of that task's session. Everything behind
that popup — the durable record, the REST endpoints, the resume path — already exists, but nothing
outside the browser can discover a question or answer it.
This spec makes answering an out-of-band, first-class API. Two new MCP tools on the external surface let a third party (or an agent running outside Kandev) list the questions it may see and answer one.
Three defects sit under that surface. Upstream has since fixed one of them; the other two are this spec's load-bearing deliverable.
-
Atomicity — FIXED UPSTREAM, not by this spec.
Store.WaitForResponsedeletes the in-memory entry the moment it observesdone, so the old 409 duplicate guard only covered a second answer arriving before the winner's waiter woke. Upstream7e2c4ae84replaced that withRepository.CompleteActiveClarificationBundle, a single-transaction row-level claim whose own doc comment states "Exactly one concurrent responder can transition the rows." This spec consumes that primitive and adds no second one. -
No ownership check — STILL OPEN, this spec closes it.
POST /api/v1/clarification/:id/respondtakes a barepending_id. The global auth middleware attaches identity, but the clarification handlers are constructed with only store/repo/message dependencies and never read the context identity. Verified againstorigin/mainon 2026-08-20:internal/clarification/handlers.gocontains no call to anyAuthorize*helper, so the hole is exactly as wide today as when this spec was first written, and it coversGET /:id,GET /:id/wait,POST /:id/respondandPOST /:id/cancelalike. Under enforced auth, any PAT holder who learns apending_idcan read and answer another user's question. This is the one clarification path that bypassestask/serviceentirely —get_task_conversation_kandev, which reaches the same messages, is scoped (ListMessagesPaginatedcallsAuthorizeSessionAccess). -
A losing caller is told the wrong thing — STILL OPEN, this spec closes it. Upstream's
httpRespondreturns 409 "clarification request is no longer active" when the claim is lost (internal/clarification/handlers.go,claimClarificationResponse). For a human clicking Submit twice that is survivable; for a machine answerer racing the browser it is not, because the caller cannot tell "someone else answered, here is what they said" from "this bundle is gone". Adding an MCP answerer makes that distinction load-bearing.
Adding a second, unauthorized, non-idempotent writer to a surface a third party can reach is the thing this card must not do. Fixing (2) and (3) on top of upstream's (1) is the deliverable.
Upstream baseline — already on main, not built by this spec
Every statement here was verified by direct read of origin/main on 2026-08-20 and is cited so no
builder re-derives it. A builder SHALL treat this section as fact, not as work.
Repository.CompleteActiveClarificationBundle(ctx, pendingID, status, responses)(internal/task/repository/sqlite/message_clarification_response.go) is the claim. It runs one transaction:claimActiveClarificationBundleissues a singleUPDATE ... SET status='responding'guarded bypending_id,type='clarification_request',COALESCE(metadata.status,'') IN ('','pending'), a non-terminal session predicate, a current-turn predicate, and a malformed-bundleNOT EXISTSguard; then it loads the claimed rows and writes the terminal metadata. It returns(messages, claimed bool, error).claimed == falsemeans someone else won or the bundle is no longer active.- Terminal status domain is
answeredorrejectedonly. The function rejects any other value withinvalid clarification terminal status. There is nocancelledclaim. - What the claim writes per message:
metadata.status = <terminal>,metadata.response_delivery_pending = true, and — onansweredonly —metadata.response = <the Answer value for that question_id>. This is the record from which a losing caller's replay is reconstructed (R2). - Every claimed message must carry a non-empty
question_id;completeClaimedClarificationMessagesreturnsclarification message %s is missing question_idotherwise, aborting the transaction. RestoreActiveClarificationBundlerolls a claimed bundle back to pending when its detached resume could not be dispatched. Upstream's failure philosophy is roll back and stay answerable, not record a failure and stay claimed.ExpireActiveClarificationBundleandDetachActiveClarificationMessagesBySessionIDare separate primitives writingexpiredandagent_disconnectedrespectively.- The workflow guard is already correct and already widened.
Service.sessionHasPendingClarificationnow callsRepository.FindActiveClarificationMessagesBySessionID, whose predicate isCOALESCE(metadata.status,'') IN ('','pending')joined to the session's current turn. The function this spec's first version proposed to modify,FindPendingClarificationMessagesBySessionID, no longer exists. - Active is upstream's word and upstream's rule. Per its approved spec: "A clarification bundle
is active only when at least one row in the bundle is pending and the bundle belongs to the
session's current turn." A detached bundle (
agent_disconnected=true) stays active and answerable. A superseded bundle (older turn) and a bundle whose session reached a terminal state are not active; superseded rows "remain transcript history but cannot drive a chat overlay, task/session pending projection, workflow guard, turn-completion detach pass, or late agent resume." - Upstream authorization: none. Confirmed absent, per Why item 2.
Terminology
- Bundle — one clarification request: 1..4 questions sharing one
pending_id. - Active — upstream's rule, quoted above. This spec adopts it verbatim as its membership test and does not restate it in its own words (D4).
- Resolution — the terminal outcome of a bundle:
answeredorrejected. - Claim —
CompleteActiveClarificationBundlereturningclaimed == true. Exactly one caller claims a bundle. - Winner — the caller whose claim succeeded. Loser — any later caller for the same bundle.
- Unscoped caller — no identity in request context (event bus, pollers, orchestrator) or a
synthetic identity (auth disabled). Matches
callerScopeintask/service/service_access.go.
Data model
This spec adds no table and no column. The clarification_resolutions table specified by the
first version of this spec is retired in full (see Retired criteria); upstream's claim writes its
terminal state into the existing task_session_messages.metadata, and that is now the sole
authority on whether a bundle is resolved.
Two identity rules survive from the first version because authorization still needs them — they were never about the claim.
- M5. Resolving
task_idandsession_idfor a bundle.session_idistask_session_messages.task_session_id, which isNOT NULLand FK-constrained, so it is always present for a bundle that has durable messages.task_idis read fromtask_session_messages.task_id, which isTEXT DEFAULT ''and can legitimately be empty:httpCreateRequestlogs a warning and continues withtaskID = ""when the session lookup fails, so such bundles exist in the wild. When the message'stask_idis empty, the system SHALL resolve it from the bundle's session row (task_sessions.task_id, declaredNOT NULL). Without this rule an empty-task_idbundle would passAuthorizeTaskAccess(ctx, "")to a lookup that fails for every scoped caller including its own owner, making it permanently unanswerable. - M5a. When neither source yields a
task_id, the bundle is unresolvable. Reachable only if the session row is missing or itstask_idis the empty string. Such a bundle SHALL be treated as not-found (A5) for every scoped caller, and SHALL be omitted from L1 for every caller, including an unscoped one. It remains answerable bypending_idby an unscoped caller whenever upstream's claim would still accept it, which preserves single-user behavior. The listing half of that decision is deliberate and is the one a builder would otherwise invent. Listing it for unscoped callers only would require the L1c join to become an outer join, which in turn forces L3's mandatorytask_idto carry some invented value for that one row — and L3 has no such value, so an MCP client parsing the field would meet anullor an empty string with no stated meaning. Omitting it keeps the L1c join inner, keeps L3'stask_idalways a real task ID, and costs nothing that matters: the bundle is still visible to a human in the chat transcript and still reachable throughget_task_conversation_kandev.
Existing state, unchanged in shape
- In-memory
PendingClarificationkeeps its role: it is the delivery channel that unblocks a still-waiting agent inside its own turn. Upstream already removed its duty as duplicate guard. - Durable per-question messages (
type = 'clarification_request') keep their metadata shape verbatim:pending_id,question_id,question,question_index,question_total,context,status,responseonce answered, plus upstream'sagent_disconnectedandresponse_delivery_pending. This spec adds no metadata key and changes no existing one.
The resolution operation
A single service-layer operation is the sole entry point for the REST respond endpoint and the MCP answer tool. It does not implement a claim; it authorizes, validates, and delegates to upstream's.
ResolveBundle(ctx, pendingID, outcome) -> (Resolution, claimed bool, error)
Ordered steps:
- Resolve identity of the bundle. Read the bundle's durable
clarification_requestmessages bypending_id, derivingsession_idandtask_idper M5. If no messages exist, return not-found. - Authorize. Call
TaskService.AuthorizeTaskAccess(ctx, taskID). A denial returns not-found. - Validate the outcome against the bundle's question set (N6–N8b, per N8c).
- Claim, by delegation. Call
CompleteActiveClarificationBundle(ctx, pendingID, terminal, responses).claimed == true→ this caller won;claimed == false→ R2; a returned error → R4a. - Winner only: deliver to the in-memory waiter and publish, using upstream's existing
RespondWithDeliveryConfirmation/ restore path unchanged (R7).
Steps 1–3 are this spec's addition. Step 4 is upstream's. Step 5 is upstream's, reached unchanged.
- P1. There SHALL be exactly one claim mechanism in the codebase.
ResolveBundleSHALL NOT insert, update, or read any claim record of its own, and SHALL NOT gate the claim on any precondition upstream's predicate does not already enforce. A second mechanism, however carefully coordinated, reintroduces the cross-entry-point race this card exists to close: a REST answer claiming one way and an MCP answer claiming another can both win, and no existing test would catch it because REST-vs-MCP races could not be written before the MCP tool existed. - P1a. What step 4 passes to the claim.
terminalSHALL beansweredorrejectedand nothing else; upstream rejects any other value.responsesSHALL be a map keyed byquestion_id, one entry per question in the bundle, whose value is that question's answer entry — the shapecompleteClaimedClarificationMessagesindexes asresponses[questionID]and writes verbatim tometadata.response. For arejectedoutcomeresponsesSHALL be nil, because upstream writes noresponsekey on that path. - P1b. The answers passed to the claim SHALL be the N3a-normalized ones, not the caller's raw
input.
metadata.responseis what R2b replays to a losing caller, so normalizing after the claim — or not at all — would make N3a's byte-identity guarantee hold for the winner's own response and break for every replay of it. Normalization is therefore applied between step 3 and step 4. - P2.
ResolveBundleSHALL NOT re-derive activeness. Whether a bundle is claimable is decided only by upstream's predicate, inside its transaction. A pre-check inResolveBundlewould be a TOCTOU window and could disagree with the authoritative predicate; steps 1–3 exist to authorize and validate, never to pre-judge the claim.
Determinism and boundary rules
- D1. A bundle's
created_atis the minimumcreated_atacross itsclarification_requestmessages. The bundle's messages are written in a loop and therefore carry distinct timestamps; without this rule L6's ordering is not well defined. - D2. Questions within a bundle are ordered by
question_indexascending, then messagecreated_atascending, then messageidascending. The two extra keys are not decoration:questionIndexFromMetadatareturns 0 for a missing or unparseablequestion_index, so a legacy or corrupt bundle can present several questions all claiming index 0. Messageidis a primary key, so the composite is total. - D3.
metadata.statuson aclarification_requestmessage is one ofpending,answered,rejected,cancelled, orexpired— theclarification.Statusconstants — or absent. For membership this spec defers entirely to D4; it does not define its own effective-pending rule. The distinction matters at one boundary and is stated so it is not invented: upstream's predicate isCOALESCE(status,'') IN ('','pending'), so an absent status counts as pending but an unrecognized one does not. The first version of this spec required the opposite for unrecognized values (fail closed). That requirement is withdrawn — see Retired criteria G5 — because two consumers disagreeing about membership is the defect, and upstream's rule is the one the workflow guard, the chat overlay, and the claim all already share. - D4. A bundle is pending — listable by L1, claimable by step 4, and countable by the workflow
guard — iff it is ACTIVE per upstream's rule. There is one membership test in the system and it
is not this spec's. Concretely, and only as a restatement of the upstream predicate a builder will
read directly: at least one of the bundle's
clarification_requestmessages hasCOALESCE(metadata.status,'') IN ('','pending'), and the bundle'sturn_idis the session's current turn perturnAuthorityPredicate, and the session's state is notcompleted,failed, orcancelled. - D4a. Legacy bundles fall out of D4 with no backfill and no migration. Every clarification
answered before either change is a set of messages carrying a terminal
status, so the first conjunct excludes it; every clarification stranded on an older turn is excluded by the second. Nothing this spec adds needs to run against historical data, which is what makes "no migration" safe rather than merely cheap. - D5. A task with an empty
workspace_idis visible to every authenticated caller, matchingauthorizeTaskID. Its bundles appear in L1 for everyone. This preserves the pre-auth-row behavior used everywhere else in the codebase. - D6. Pagination is not a snapshot. A bundle that becomes inactive between two pages simply
disappears; because the cursor is a
(created_at, pending_id)key rather than an offset, no other bundle shifts position or is skipped. A bundle created with acreated_atearlier than an already-issued cursor (only reachable via clock adjustment) SHALL NOT be returned to that cursor's holder; it is returned on any fresh, cursor-less call. - D7.
age_secondsuses the server clock and is floored at 0, so a bundle whose storedcreated_atis in the future reports0rather than a negative number.