Files
kandev/docs/specs/external-question-answering/spec.md
T
nova28 c82668c789 feat(mcp): expose agent clarification questions and permission requests externally (#2875)
Co-authored-by: Vojtěch Biberle <vojtech.biberle@gmail.com>
Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-08-22 15:39:40 +00:00

1018 lines
79 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
status: draft
created: 2026-08-16
revised: 2026-08-20
owner: nova28
---
# External Question Answering — authorized, discoverable, idempotent clarification resolution
> **Revision note (2026-08-20, spec round 6).** This spec was first written against a
> `main` on which nothing made clarification resolution atomic. While it was in Build,
> upstream commit `7e2c4ae84` ("fix: enforce active clarification lifecycle", #2669) merged
> its own approved spec, `docs/specs/clarification-active-lifecycle/spec.md`, which landed an
> atomic current-turn claim and a turn-supersession lifecycle. That work **supersedes this
> spec's entire claim mechanism** — the `clarification_resolutions` table, `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.
1. **Atomicity — FIXED UPSTREAM, not by this spec.** `Store.WaitForResponse` deletes the in-memory
entry the moment it observes `done`, so the old 409 duplicate guard only covered a second answer
arriving before the winner's waiter woke. Upstream `7e2c4ae84` replaced that with
`Repository.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.
2. **No ownership check — STILL OPEN, this spec closes it.** `POST /api/v1/clarification/:id/respond`
takes a bare `pending_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 against `origin/main` on 2026-08-20: `internal/clarification/handlers.go`
contains **no** call to any `Authorize*` helper, so the hole is exactly as wide today as when
this spec was first written, and it covers `GET /:id`, `GET /:id/wait`, `POST /:id/respond` and
`POST /:id/cancel` alike. Under enforced auth, any PAT holder who learns a `pending_id` can read
and answer another user's question. This is the one clarification path that bypasses
`task/service` entirely — `get_task_conversation_kandev`, which reaches the same messages, *is*
scoped (`ListMessagesPaginated` calls `AuthorizeSessionAccess`).
3. **A losing caller is told the wrong thing — STILL OPEN, this spec closes it.** Upstream's
`httpRespond` returns **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: `claimActiveClarificationBundle` issues a single `UPDATE ... SET status='responding'`
guarded by `pending_id`, `type='clarification_request'`,
`COALESCE(metadata.status,'') IN ('','pending')`, a **non-terminal session** predicate, a
**current-turn** predicate, and a malformed-bundle `NOT EXISTS` guard; then it loads the claimed
rows and writes the terminal metadata. It returns `(messages, claimed bool, error)`.
`claimed == false` means someone else won or the bundle is no longer active.
- **Terminal status domain is `answered` or `rejected` only.** The function rejects any other value
with `invalid clarification terminal status`. There is no `cancelled` claim.
- **What the claim writes per message**: `metadata.status = <terminal>`,
`metadata.response_delivery_pending = true`, and — on `answered` only — `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`**;
`completeClaimedClarificationMessages` returns `clarification message %s is missing question_id`
otherwise, aborting the transaction.
- **`RestoreActiveClarificationBundle`** rolls 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*.
- **`ExpireActiveClarificationBundle`** and **`DetachActiveClarificationMessagesBySessionID`** are
separate primitives writing `expired` and `agent_disconnected` respectively.
- **The workflow guard is already correct and already widened.**
`Service.sessionHasPendingClarification` now calls
`Repository.FindActiveClarificationMessagesBySessionID`, whose predicate is
`COALESCE(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: `answered` or `rejected`.
- **Claim** — `CompleteActiveClarificationBundle` returning `claimed == 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 `callerScope` in `task/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_id` and `session_id` for a bundle.** `session_id` is
`task_session_messages.task_session_id`, which is `NOT NULL` and FK-constrained, so it is always
present for a bundle that has durable messages. `task_id` is read from
`task_session_messages.task_id`, which is `TEXT DEFAULT ''` and **can legitimately be empty**:
`httpCreateRequest` logs a warning and continues with `taskID = ""` when the session lookup fails,
so such bundles exist in the wild. When the message's `task_id` is empty, the system SHALL resolve
it from the bundle's session row (`task_sessions.task_id`, declared `NOT NULL`). Without this rule
an empty-`task_id` bundle would pass `AuthorizeTaskAccess(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 its `task_id` is 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 by `pending_id` by 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 mandatory `task_id` to carry some invented value for that one row — and L3 has no
such value, so an MCP client parsing the field would meet a `null` or an empty string with no
stated meaning. Omitting it keeps the L1c join inner, keeps L3's `task_id` always a real task ID,
and costs nothing that matters: the bundle is still visible to a human in the chat transcript and
still reachable through `get_task_conversation_kandev`.
### Existing state, unchanged in shape
- **In-memory** `PendingClarification` keeps 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`, `response` once answered, plus upstream's `agent_disconnected` and
`response_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:
1. **Resolve identity of the bundle.** Read the bundle's durable `clarification_request` messages by
`pending_id`, deriving `session_id` and `task_id` per M5. If no messages exist, return not-found.
2. **Authorize.** Call `TaskService.AuthorizeTaskAccess(ctx, taskID)`. A denial returns not-found.
3. **Validate the outcome** against the bundle's question set (N6–N8b, per N8c).
4. **Claim, by delegation.** Call `CompleteActiveClarificationBundle(ctx, pendingID, terminal,
responses)`. `claimed == true` → this caller won; `claimed == false` → R2; a returned error → R4a.
5. **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.** `ResolveBundle` SHALL 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.** `terminal` SHALL be `answered` or `rejected` and nothing
else; upstream rejects any other value. `responses` SHALL be a map **keyed by `question_id`**, one
entry per question in the bundle, whose value is that question's answer entry — the shape
`completeClaimedClarificationMessages` indexes as `responses[questionID]` and writes verbatim to
`metadata.response`. For a `rejected` outcome `responses` SHALL be nil, because upstream writes no
`response` key on that path.
- **P1b. The answers passed to the claim SHALL be the N3a-normalized ones, not the caller's raw
input.** `metadata.response` is 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. `ResolveBundle` SHALL NOT re-derive activeness.** Whether a bundle is claimable is decided
**only** by upstream's predicate, inside its transaction. A pre-check in `ResolveBundle` would 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_at` is the minimum `created_at` across its `clarification_request`
messages.** 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_index` ascending, then message
`created_at` ascending, then message `id` ascending.** The two extra keys are not decoration:
`questionIndexFromMetadata` returns **0** for a missing or unparseable `question_index`, so a
legacy or corrupt bundle can present several questions all claiming index 0. Message `id` is a
primary key, so the composite is total.
- **D3. `metadata.status` on a `clarification_request` message is one of `pending`, `answered`,
`rejected`, `cancelled`, or `expired`** — the `clarification.Status` constants — 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 is `COALESCE(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_request` messages has
`COALESCE(metadata.status,'') IN ('','pending')`, **and** the bundle's `turn_id` is the session's
current turn per `turnAuthorityPredicate`, **and** the session's state is not `completed`,
`failed`, or `cancelled`.
- **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_id` is visible to every authenticated caller**, matching
`authorizeTaskID`. 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 a `created_at` earlier 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_seconds` uses the server clock and is floored at 0**, so a bundle whose stored
`created_at` is in the future reports `0` rather than a negative number.
## What
Each criterion below is observable through the HTTP API, the MCP tool surface, or the database.
### Resolution semantics (applies to every answering surface)
- **R1.** When a caller submits a resolution for an **active** bundle and
`CompleteActiveClarificationBundle` returns `claimed == true`, that caller SHALL be treated as the
winner and SHALL be the only caller that performs step 5.
- **R2. A losing caller SHALL be told what the winner decided, not merely that it lost.** When the
claim returns `claimed == false`, the system SHALL re-read the bundle's `clarification_request`
messages and branch on their durable status:
- **At least one message carries `answered` or `rejected`** — a winner exists. The system SHALL
return a **success** response carrying `claimed: false` plus the winner's `status` and
reconstructed `response` (R2b), SHALL NOT modify any message, and SHALL NOT publish any event.
- **No message carries `answered` or `rejected`** — there is no winner; the bundle simply is not
active (superseded turn, terminal session, `expired`, `cancelled`, or malformed). The system
SHALL return **409** with upstream's existing message, unchanged.
This branch is the load-bearing addition, because `CompleteActiveClarificationBundle` returns the
same `claimed == false` for both states and a caller cannot distinguish them. Collapsing them —
in either direction — produces a concrete lie: reporting 409 for a duplicate answer hides the
winner's payload, which is the whole point of idempotent replay; reporting "success, here is the
winner's answer" for a superseded bundle invents a winner that does not exist and would have the
MCP tool tell an agent its answer was superseded by an answer nobody gave.
- **R2a. The re-read is not required to agree with the failed claim, and SHALL NOT be retried.** The
bundle can change again between the claim and the re-read. Whatever the re-read observes is what
the caller is told; a single read is the contract. Retrying to obtain a "more settled" answer would
race the same transitions indefinitely, and every outcome of the re-read is already a truthful
statement about a real moment.
- **R2b. Reconstructing the winner's `response`.** `status` is the status of the **first message in
D2 order** whose status is `answered` or `rejected`; a bundle claimed by upstream is uniform, so
this tiebreak only ever binds on a legacy mixed bundle, where it is still total and reproducible.
`answers` is built by walking the bundle's messages in D2 order and taking each message's
`metadata.response` where present, in that order; a message without one contributes no entry, so a
rejection yields `[]`. `rejected` is `true` when `status` is `rejected`.
**`reject_reason` SHALL be the empty string on every replay.** Upstream stores no reject reason on
the message rows — `completeClaimedClarificationMessages` writes only `status`,
`response_delivery_pending` and, for `answered`, `response` — so there is nowhere to read it from.
This is stated rather than repaired: repairing it means adding a metadata key to a shape the
*Upstream baseline* freezes, which belongs to the active-lifecycle card, not this one. A loser
therefore learns *that* the bundle was declined and *by what outcome*, but not the declining
caller's prose.
- **R2c. Validation runs before the loser branch.** A malformed submission against an
already-resolved bundle SHALL receive the same validation error it would receive against an active
one, and SHALL NOT receive the winner's payload. Returning success for a request the system could
not parse would tell a caller its malformed answer had been accepted. This follows from N8c
ordering validation ahead of step 4, and is restated here because R2 is where it is observable.
- **R3.** While two callers submit resolutions for the same `pending_id` concurrently, exactly one
SHALL observe `claimed == true` and the other SHALL observe R2's winner branch. This SHALL hold
when the two callers are served by different HTTP requests, and — the case no existing test can
cover, because the MCP tool did not exist when upstream's tests were written — **when one is the
web UI over REST and the other is `answer_question_kandev` over MCP**.
- **R4.** The durable claim SHALL complete before any resume event is published, so a loser can
never trigger a resume. This holds by construction once P1 is satisfied: the claim is a committed
transaction and step 5 runs only for the winner.
- **R4a. When `CompleteActiveClarificationBundle` returns an error rather than a `claimed` boolean**,
the system SHALL return **500**, SHALL NOT retry, and SHALL log the `pending_id` and the error.
Reachable causes are upstream's own: a database failure, a claimed/loaded row-count mismatch, and
a claimed message with an empty `question_id`. The last is why L16 bundles are excluded from L1
and are not answerable at all (L16); it is not a caller error and SHALL NOT be reported as 400.
- **R6.** When the backend restarts between a bundle's creation and its resolution, a subsequent
resolution SHALL still be claimed exactly once, and SHALL succeed whenever the bundle is still
active. The claim SHALL NOT depend on in-memory state. A plain restart does not by itself end a
turn or terminate a session, so a bundle stranded by one remains active and answerable; a bundle
whose session has since accepted a newer turn does not, per D4.
- **R7. The winner's delivery and resume path is upstream's, reached unchanged.** The system SHALL
call the existing `Store.RespondWithDeliveryConfirmation` path and SHALL NOT re-implement live
delivery, detached resume dispatch, or the `RestoreActiveClarificationBundle` rollback. A live
waiter therefore receives the response in the same turn; a detached current-turn bundle resumes
through upstream's bounded-wait dispatch; a dispatch that cannot be accepted rolls the claim back
and the bundle becomes answerable again.
- **R7a. HTTP 200 on a winning resolution therefore means more than "recorded".** It means the claim
committed **and** the response either reached a live waiter or had one resume dispatch accepted.
A failure of either yields a 5xx from upstream's existing handling, with the bundle rolled back.
This is a stronger guarantee than the four-valued `resume` field the first version of this spec
specified, which is why that field is retired rather than reproduced (see *Retired criteria* R8a):
there is no longer a success case in which the caller must be told the resume is in doubt.
- **R9. A rejection SHALL produce the same agent-visible outcome as a human skip.** It is claimed
with `status = rejected`, and upstream's delivery path decides what the agent sees: a live waiter
receives a `clarification.Response` with `rejected: true` and the caller's reason; a detached
current-turn bundle is persisted rejected without resuming the agent. The wire format does not
distinguish the answerer — a reject from an external agent means the same thing to the blocked
agent as a human skip.
### REST status codes and response envelope
- **R10.** `POST /api/v1/clarification/:id/respond` SHALL use exactly these outcomes. This table is
the contract W2/W3 and the E2E duplicate-submit case are written against.
| Outcome | Status | Body |
|---|---|---|
| won the claim (R1) | 200 | `{"success": true, "claimed": true, "status": "...", "response": {...}}` |
| lost to a resolved winner (R2 winner branch) | 200 | same shape, `"claimed": false`, winner's `status`/`response` |
| not active, no winner (R2 no-winner branch) | 409 | `{"error": "clarification request is no longer active"}` |
| validation failure (N6–N8b) | 400 | `{"error": "<message naming the offending field>"}` |
| unknown / unauthorized `pending_id` | 404 | `{"error": "clarification request not found"}` |
| claim error (R4a) or delivery failure (R7) | 500 | `{"error": "<message>"}` |
- **R11. The `success` key SHALL be retained** on `POST` responses so an existing client that only
checks `res.ok` and `success` keeps working. `claimed`, `status`, and `response` are additive.
**The 409 is narrowed, not removed.** Upstream returns 409 for every `claimed == false`; after
this change 409 means *only* "not active and no winner", and the duplicate-answer case that used
to share it becomes a 200 with `claimed: false`. Clients that already treat 409 as a successful
submit (W2) keep working in both cases because they also treat 200 as success.
- **R12. `response` SHALL always emit `answers`, `rejected` and `reject_reason`, and each entry's
`question_id`, `selected_options` and `custom_text`**, using `[]` for an empty array and `""` for
an empty string. `clarification.Response` declares `omitempty` on `Answers`, `Rejected`,
`RejectReason` and `Answer.SelectedOptions`/`CustomText`, so marshalling the struct as it stands
omits the **key entirely** for an empty slice, a `false` bool or an empty string — a rejection's
payload would carry no `answers` key at all and a client reading `response.answers` would get
`undefined`. The struct tags **SHALL NOT be changed**: they are the wire shape of
`ask_user_question_kandev`'s own tool result, which *Out of scope* freezes. An explicit
serialization for this envelope is therefore required, and it binds the REST body and the MCP
result alike.
### Authorization
This is the deliverable upstream did not build. Every criterion here is unchanged in intent from the
first version of this spec; only the endpoint set's behavior around the claim has moved.
- **A1.** `POST /api/v1/clarification/:id/respond` SHALL deny a request whose caller cannot access
the task owning that `pending_id`.
- **A2.** `GET /api/v1/clarification/:id`, `GET /api/v1/clarification/:id/wait`, and
`POST /api/v1/clarification/:id/cancel` SHALL apply the same check.
- **A3.** A denial under A1/A2 SHALL be a **404** with a body indistinguishable from a nonexistent
`pending_id`. It SHALL NOT be a 403 and SHALL NOT include the question text, option labels, task
ID, session ID, or workspace ID.
- **A4.** An unscoped caller SHALL be authorized for every `pending_id`. Concretely: **with auth
disabled, no caller is ever newly denied**, and any behavior this spec does not deliberately
change is unchanged. The deliberate changes below apply in **both** auth modes — there is one code
path, not an auth-disabled variant:
1. a duplicate submission against a bundle that already has a winner returns 200 with
`claimed: false` and the winner's payload, instead of 409 (R2, R11);
2. every successful response gains `claimed`, `status`, and `response` fields (R10, R12);
3. a `pending_id` with no durable messages returns 404 (A5);
4. the new validation rules reject submissions that pass today: unknown option IDs (N8),
`rejected: true` combined with answers (N8a), and `reason`/`custom_text` over 2000 runes (N8b);
5. an answers submission against a bundle whose messages carry no `question_id` is rejected before
the claim (L16) instead of reaching upstream's claim and failing there with a 500;
6. `GET /:id/wait` on a `pending_id` with **no durable messages** returns **404** instead of
today's **504** (A5a).
This list is exhaustive. A behavior not named here SHALL be identical to the pre-change behavior
for an unscoped caller on all four endpoints. In particular, 409 for a genuinely inactive bundle
is **not** on this list: it is preserved exactly as upstream returns it today.
- **A5.** A `pending_id` whose durable messages cannot be found SHALL produce the same 404 as A3.
This also covers the M5a case where a bundle's `task_id` cannot be resolved from either source,
for a scoped caller.
- **A5a. A5 binds all four endpoints, in both auth modes — including `GET /:id/wait`, which returns
504 for that input today.** A8 resolves the authorizing `task_id` from the durable messages on all
four endpoints and A7 puts that resolution ahead of the in-memory read, so a `pending_id` with no
durable messages fails at step 1 on every endpoint, for an unscoped caller as much as a scoped one.
It is enumerated rather than avoided because the alternative breaks a security property. Exempting
the two read endpoints from A5 would leave `GET /:id/wait` returning 404 for a **foreign** bundle
(A3) and 504 for a **nonexistent** one — a status-code oracle telling an unauthorized caller
exactly which `pending_id`s exist, which is the precise distinction A3 exists to prevent. A
compatibility promise can be amended by adding a line to A4; an existence-disclosure channel
cannot be amended at all. The 504 is preserved where it means something: an authorized caller
waiting on a bundle that **does** exist.
- **A6.** `POST /api/v1/clarification/request` (creation) SHALL be unchanged by this spec. It is
called by the in-session agent path, which carries the task owner's identity via `internal/mcp/scope`.
- **A7.** The A2 authorization check SHALL run **before** the in-memory read on both read endpoints,
so an unauthorized caller receives A3's 404 on either and can never distinguish a foreign bundle
from a nonexistent one by status code. An **authorized** caller's post-authorization behavior is
unchanged **for a bundle whose durable messages exist**: 404 from `GET /:id` when the in-memory
entry is gone, 504 from `GET /:id/wait` on drain or timeout.
- **A8.** On all four endpoints the authorizing `task_id` comes from the durable messages via M5,
never from the in-memory `Request.TaskID`. A7's ordering requires it — the check runs before the
in-memory read, so the in-memory value is not yet available — and it also removes a discrepancy:
the in-memory `Request.TaskID` is populated from the same possibly-empty session lookup as the
message column, so trusting it would reintroduce the M5 hole on two endpoints while the other two
resolved it correctly.
- **A9. Cancel is authorized without being claimed.** `POST /:id/cancel` does **not** go through
`ResolveBundle` — X1's routing is retired — so A2's check SHALL be applied by running
`ResolveBundle` steps 1 and 2 alone (resolve the bundle's `task_id` per M5, then
`AuthorizeTaskAccess`) ahead of the handler's existing body, and returning A3's 404 on either
failure. Everything after that check is today's cancel, unchanged: it still requires a live
in-memory entry, still returns 404 without one, and still applies its per-question updates in a
log-and-continue loop. Stating this is what stops a builder inferring from A2 that cancel must be
re-routed through the claim, which P1 and upstream's answered/rejected-only terminal domain forbid.
**BREAKING CHANGE, intentional.** An authenticated caller that today answers any bundle by bare
`pending_id` will receive 404 for bundles outside its workspaces. The only in-tree production caller
is the web UI, which answers from a task it can already read, so it is unaffected — subject to W1
below. The six auth-mode-independent changes enumerated in A4 are also intentional and accepted.
### Web client
- **W1.** The web UI's clarification POSTs (`apps/web/hooks/domains/session/use-clarification-group.ts`,
both call sites) SHALL send credentials. They currently use a bare `fetch` with the default
`credentials: "same-origin"`, unlike the shared client (`lib/api/client.ts`, `credentials: "include"`).
In split-origin dev mode (`__KANDEV_API_PORT` set, browser and API on different ports) the session
cookie is therefore dropped, so with auth enabled the request is already rejected by the global
middleware before this spec's changes. Authorization becomes load-bearing on this route, so the
omission is fixed here.
- **W2.** The web UI SHALL treat a `claimed: false` success as a successful submit and close the
overlay, the same way it already treats a 409. Both outcomes remain "someone else settled this";
the client's existing 409 handling SHALL be retained unchanged, because R11 keeps 409 reachable
for a genuinely inactive bundle.
- **W3.** On a `claimed: false` **200** the web UI SHALL apply the **winner's** returned `response`
to the bundle's local message metadata, not the answers this client submitted.
`postClarificationBatch` and `postClarificationSkip` currently inspect only `res.ok` / `res.status`,
never the body, and `safeApplyResolvedStatus` then optimistically writes **this client's** answers
into `metadata.response`. Because R2 guarantees the losing submit produces no
`session.message.updated` broadcast, nothing would ever correct that write: the losing tab would
display the losing answers as the transcript until a reload. Both call sites SHALL parse the
response body and pass the winner's answers to the optimistic update. When `claimed` is absent from
the body (an older backend, or the 409 path), the client SHALL keep today's behavior.
- **W3a. The winner's `status` domain is `answered` or `rejected`.** `safeApplyResolvedStatus` and
`applyResolvedStatusToBundle` already accept exactly `"answered" | "rejected"`, which now matches
the backend's terminal domain exactly, so **no union widening is required.** This is stated
because the first version of this spec required adding `"cancelled"` to that union; upstream's
claim cannot produce a cancelled winner (its terminal domain is answered/rejected only), so a
cancelled bundle now reaches the client as the 409 no-winner branch instead. See *Retired
criteria* W3a.
- **W4.** The overlay's free-text input (`clarification-input-overlay.tsx`, which enforces no limit
today) SHALL stop a human at the same boundary N8b enforces on the server, so a long answer is
caught at the input rather than returning an opaque 400 on submit.
- **W4a. The client-side guard SHALL count runes, and the HTML `maxLength` attribute alone SHALL NOT
be the mechanism.** N8b's cap is 2000 UTF-8 **runes**, chosen explicitly so a non-Latin answer is
not cut short. The `maxLength` attribute counts **UTF-16 code units**, so every astral character —
emoji, and much CJK Extension B — consumes two of its budget. `maxLength={2000}` would stop a user
at 1000 emoji the server would have accepted: not the opaque-400 failure W4 exists to prevent, but
the mirror-image one, a client refusing input the contract permits, with no error message at all
because `maxLength` fails silently. The overlay SHALL enforce the limit by counting runes (code
points) on the input's value and SHALL surface the boundary to the user rather than silently
truncating. `maxLength` MAY be set in addition, as a coarse backstop, only at a value that cannot
reject anything the server accepts — **no lower than 4000**, since a rune is at most two UTF-16
code units. It SHALL NOT be set to 2000. Boundary values follow N8b exactly: 2000 runes is
accepted, 2001 is not, and the count is over code points, not bytes and not UTF-16 units.
### `list_pending_questions_kandev` (external MCP surface only)
- **L1.** The tool SHALL return every **active** bundle (D4) in the workspaces visible to the caller,
and no bundle outside them.
- **L1a.** Visibility SHALL be resolved by the same rule as `filterWorkspacesForCaller`
(`task/service/service_access.go`): an unscoped caller sees every workspace; a scoped caller sees a
workspace whose `owner_id` is empty or matches the caller. The tool SHALL resolve that
workspace-ID set first and apply it as a **predicate of the bundle query**, not as a post-query
filter over an already-limited page. Per-bundle `AuthorizeTaskAccess` calls are NOT the mechanism:
N calls per page would be N round trips, and — decisively — filtering after `LIMIT` would make
L10's `limit` mean "rows examined" rather than "bundles returned", so a page could come back short
or empty while more matching bundles exist, and a cursor-polling caller could not distinguish that
from exhaustion. `limit` counts **returned** bundles.
- **L1b.** When the resolved workspace-ID set is empty — a scoped caller who owns no workspaces and
can see no unowned ones — the tool SHALL omit the `workspace_id IN (...)` **term** from the L1c
disjunction rather than issuing it, because an empty `IN ()` is a syntax error on both dialects.
It SHALL NOT short-circuit the whole query, and there is **no** whole-query short-circuit branch in
this tool. L1c's other disjuncts are predicates on the task row rather than on the caller, so they
remain applicable no matter what the caller can see: a scoped caller with no visible workspaces can
still legitimately be shown bundles whose task has an empty `workspace_id` or a dangling workspace
reference, exactly as `authorizeTaskID` would allow them to answer those bundles.
- **L1c. The visibility predicate SHALL reproduce `authorizeTaskAccess`, not a narrower
workspace-membership test.** The list tool and the answer tool MUST agree on visibility: a bundle
`answer_question_kandev` will answer and `list_pending_questions_kandev` will not show is a silent
discovery hole, and discovery is what this card exists to add. `AuthorizeTaskAccess` delegates to
`authorizeTaskID`, which permits a scoped caller in **two** cases a bare
`workspace_id IN (visible set)` term cannot express. The predicate is therefore a three-way
disjunction, evaluated over the **M5-resolved** task:
1. **The task's `workspace_id` is empty.** `authorizeTaskID` returns nil early on
`task.WorkspaceID == ""`. This is D5's case, and D5 already states such bundles appear in L1
for everyone — the predicate must actually deliver that.
2. **The task's `workspace_id` names no existing workspace row.** `authorizeTaskID` treats a failed
workspace lookup as visible, by an explicit `//nolint:nilerr` fallback whose comment reads "a
dangling workspace reference should not hide the task from the single user who can already see
everything else about it". `filterWorkspacesForCaller` cannot express this because it filters a
list of workspaces that *exist*.
3. **The task's `workspace_id` is in the L1a visible set.** This is the ordinary case, and it is
the disjunct the `IN` term expresses.
For an unscoped caller the predicate is satisfied unconditionally, matching `callerScope` returning
`ok=false`.
- **L1d. Mechanism for L1c, so it is not invented.** Disjunct 1 is a comparison against the literal
empty string, because `tasks.workspace_id` is `TEXT NOT NULL DEFAULT ''` with no foreign key —
empty is its default, not an anomaly. Disjunct 2 is an absence test against the `workspaces` table
(`NOT EXISTS (SELECT 1 FROM workspaces w WHERE w.id = t.workspace_id)`, or the equivalent left join
with a null check); it is a plain relational predicate and needs **no** `internal/db/dialect`
helper. Disjunct 3 is the workspace-ID set from L1a, subject to L1b's empty-set handling. The three
are combined with `OR` and the whole disjunction is `AND`-ed with D4's activeness test, all inside
the single bundle query — per L1a visibility is a query predicate, never a post-`LIMIT` filter.
**Separately from the disjunction**, the join reaches the task through **M5's rule** —
`task_session_messages.task_id` when non-empty, otherwise the bundle's session row's `task_id` —
because the obvious `JOIN tasks ON tasks.id = messages.task_id` silently drops every legacy
empty-`task_id` bundle for **every** caller, unscoped included. That is a join-path rule, not a
fourth disjunct, and it decides which task row disjuncts 1–3 evaluate against.
- **L2. The tool SHALL derive its result from the durable `clarification_request` messages**, NOT
from `Store.ListPending`, and SHALL therefore return the correct set after a backend restart.
`Store.ListPending` returns nothing after a restart because the map is empty. The membership test
is **D4**, applied as predicates of the bundle query.
- **L2a. The list query SHALL reuse upstream's activeness expression rather than restate it.** D4 is
upstream's rule, and the list tool is the third consumer of it after the workflow guard and the
claim. The current-turn and non-terminal-session predicates SHALL be built from the same helpers
those two already use (`turnAuthorityPredicate`, the non-terminal-session predicate) rather than
hand-written, so a future change to activeness cannot leave this tool behind. This is the concrete
form of upstream's own requirement that *"all backend consumers derive active clarification state
from one repository rule"*.
- **L3.** Each returned bundle SHALL carry: `pending_id`, `task_id`, `session_id`, `created_at`
(RFC3339 UTC), `age_seconds` (integer, server clock minus `created_at`, floored at 0), `context`,
and `questions`.
- **L4.** Each question SHALL carry `question_id`, `title`, `prompt`, `status`, and `options`, where
each option carries `option_id`, `label`, and `description` — the exact identifiers
`answer_question_kandev` accepts.
- **L4a. Every field named in L3 and L4 is always present and is never JSON `null`.** When a value is
unknown, absent, or unparseable:
- a **string** field (`task_id`, `session_id`, `context`, `question_id`, `title`, `prompt`,
`option_id`, `label`, `description`) SHALL be emitted as the **empty string** `""`;
- an **array** field (`questions`, `options`) SHALL be emitted as an **empty array** `[]`;
- `age_seconds` SHALL be emitted as an integer, floored at 0 per D7, never null;
- `created_at` and `status` have no unknown case: `created_at` is D1's `MIN(created_at)` over rows
that exist by construction, and a message whose `status` key is absent is reported as the string
`pending`, matching the `COALESCE(status,'') IN ('','pending')` that admitted it.
Without this rule a builder emitting Go zero values through one path and `null` through another
produces a shape that changes between bundles, and an MCP client iterating `options` or reading
`label` on a degraded bundle would meet `null` where the contract implied an array or a string.
- **L5.** Questions within a bundle SHALL be ordered by D2's total key.
- **L6.** Bundles SHALL be ordered by `created_at` ascending, then by `pending_id` ascending. The
`pending_id` tiebreak exists solely to make the order total for cursor pagination; it carries no
meaning. Oldest-first is the useful order because the oldest blocked agent is the most urgent.
- **L7.** The tool SHALL accept an optional `workspace_id`. When supplied, results SHALL be limited
to bundles whose task — resolved per M5 — carries exactly that `workspace_id`, and a workspace the
caller cannot access SHALL produce the same empty-result response as an empty workspace (no
existence leak, consistent with A3).
- **L7a. How `workspace_id` composes with L1c, and what the empty string means.**
1. **`workspace_id` is one additional predicate `AND`-ed with the whole L1c disjunction** — never a
substitute for it, and never a fourth disjunct. Adding an `AND` term can only ever NARROW the
result, so supplying `workspace_id` can never reveal a bundle an unfiltered call would withhold.
2. L7's empty-result guarantee for an inaccessible workspace **falls out of that `AND`** and needs
no short-circuit branch. For a workspace that exists and the caller cannot see: disjunct 1 is
false (the task's `workspace_id` is non-empty), disjunct 2 is false (the workspace row exists),
and disjunct 3 is false (it is not in the L1a set). L1b's "there is no whole-query short-circuit
in this tool" therefore holds without exception.
3. A **fabricated or deleted** `workspace_id` leaves disjunct 2 satisfiable, so bundles whose task
carries that dangling reference ARE returned. That is correct rather than a leak: they are
exactly the bundles `authorizeTaskID`'s dangling-workspace fallback already lets this caller
answer, and an unfiltered L1 call would have listed them anyway. Nothing is disclosed about a
workspace that does not exist.
4. **`workspace_id: ""` means the parameter was not supplied.** It SHALL NOT be read as "filter to
tasks whose `workspace_id` is empty", even though disjunct 1 and D5 make that a real class. An
omitted optional string decodes to `""`, so the other reading would silently narrow every caller
who omits the argument down to the empty-workspace class alone. Those bundles are reached by
making an **unfiltered** call. This spec deliberately provides no filter selecting them
exclusively; that is a named exclusion, not an oversight.
- **L8.** The tool SHALL accept an optional `created_since` (RFC3339). When supplied, only bundles
with `created_at >= created_since` SHALL be returned. The parameter is named for the column it
filters: a bundle's `created_at` never changes, so an `updated_since` name would promise
change-feed semantics this tool does not have.
- **L9.** The tool SHALL accept an optional `cursor` — the opaque encoding of the last returned
`(created_at, pending_id)` pair — and SHALL return only bundles ordered strictly after it under L6.
It SHALL return a `next_cursor` when more results exist and an empty `next_cursor` when they do not.
- **L10.** The tool SHALL accept an optional `limit`, defaulting to **50** and capped at **200**. A
`limit` below 1, or absent, SHALL be treated as the default; a `limit` above the cap SHALL be
clamped to the cap rather than rejected. Per L1a, `limit` counts bundles actually returned.
- **L11.** The response envelope SHALL be
`{"bundles": [...], "count": <int>, "next_cursor": "<string>"}`. `bundles` is the top-level array —
each element carries its own `questions` array per L3, so the two names never collide. `count` is
the number of elements in `bundles` on **this page** and is always equal to `len(bundles)`; it is a
convenience, not a total. When no bundle matches, the tool SHALL return an empty `bundles` array,
`count: 0`, and an empty `next_cursor`, and SHALL NOT return an error.
- **L11a.** A grand total across all pages is deliberately NOT provided. Producing one would require
a second, unbounded, authorization-filtered aggregate query per call, and it would be stale the
moment it was computed (D6). Callers that need to know whether more work exists read `next_cursor`.
- **L12. A bundle whose per-question messages disagree on `status` (some pending, some terminal) is
returned iff it satisfies D4** — that is, iff at least one message is still pending and the bundle
is on the current turn of a non-terminal session. Each question SHALL carry its own `status`. Such
a bundle is a legacy artifact: upstream's claim writes every row in one transaction, so nothing
produced after this change can be mixed. Returning it lets a caller finish it; the claim will then
transition only the still-pending rows, which is exactly upstream's predicate.
- **L13.** An unparseable `created_since` or an unparseable/corrupt `cursor` SHALL produce a
validation error naming the offending argument. Neither SHALL be silently ignored: a caller polling
with a cursor it thinks is being honored, that is in fact being dropped, re-reads the whole backlog
every tick and re-answers questions it already handled.
- **L14.** Supplying both `cursor` and `created_since` SHALL be accepted; both constraints apply
(intersection). `cursor` is the pagination position, `created_since` is a filter, and they are
independent.
- **L15.** A bundle whose durable messages carry no parseable `question` metadata SHALL still be
returned when it satisfies D4 and L16, with the affected question carrying its `question_id` and,
per L4a, an empty `title`, `prompt` and `options` rather than null ones. Such a question cannot be
answered by option ID — every `selected_options` entry would fail N8 against an empty option list —
but hiding the bundle would strand its agent invisibly, so it remains answerable by `custom_text`
alone (N7) or by rejection.
- **L16. A bundle in which ANY message carries an empty or absent `question_id` SHALL be excluded
from L1, and SHALL be rejected by `answer_question_kandev` and the REST endpoint before the claim.**
Upstream's `completeClaimedClarificationMessages` returns
`clarification message %s is missing question_id` and aborts its transaction, so such a bundle
cannot be resolved by any outcome — not by an answer, and not by a rejection. Listing a bundle no
caller can act on is a discovery lie: the tool would advertise a blocked agent and every attempt to
clear it would return 500. Excluding it is therefore the honest contract, and it is the whole
bundle that is excluded, not the offending message, because the claim aborts on the first one it
meets. The pre-claim rejection SHALL name the condition rather than surfacing upstream's 500 (R4a).
A human still sees the bundle in the chat transcript, and it is still reachable through
`get_task_conversation_kandev`. Making such a bundle resolvable requires a change to upstream's
claim and is named in *Out of scope*.
### `answer_question_kandev` (external MCP surface only)
- **N1.** The tool SHALL accept `pending_id` plus either `answers` (one entry per question) or
`rejected: true` with an optional `reason`.
- **N2.** The tool SHALL resolve the bundle through the same `ResolveBundle` operation as the REST
endpoint, and SHALL therefore inherit R1–R9 and A1–A5 without a second code path.
- **N3.** On winning, the tool SHALL return `claimed: true` with the recorded `status` and the
normalized `response` (N3a).
- **N3a. Normalization** is the canonical form of the answer payload. It is defined so that two
callers submitting semantically identical answers produce byte-identical **answer payloads** — the
`answers`, `rejected` and `reject_reason` fields. Rules:
1. `answers` entries are ordered by the bundle's own question order (L5), **not** by the order the
caller supplied them.
2. Within an entry, `selected_options` is ordered by the option's position in the question's
`options` array, and exact duplicates are removed.
3. `custom_text` and `reason` are stored verbatim after trimming leading and trailing whitespace.
No other transformation is applied. Trimming happens **after** N8b's length validation, which
runs on the caller's raw input at step 3, so a value one rune over the cap is rejected even when
trimming would have brought it under.
4. An absent `answers`, `selected_options`, or `options` array is emitted as an empty JSON array
`[]`, never `null` and never as an **omitted key**. **R12 states the mechanism** and is not
optional here: the guarantee is unreachable through `encoding/json` on `clarification.Response`
as tagged.
- **N4.** On losing to a resolved winner, the tool SHALL return `claimed: false` with the winner's
`status` and reconstructed `response` (R2b), and SHALL NOT report an error. Answering an
already-answered question is a successful no-op that tells the caller what the answer was.
- **N4a. On losing to an inactive bundle with no winner (R2's second branch), the tool SHALL report
an error** naming the bundle as no longer active, distinct from both success and not-found. A
caller must be able to tell "you were beaten, here is the answer" from "this question is no longer
answerable and nobody answered it", because the two imply different next actions: the first is
done, the second may need a human.
- **N5.** A `pending_id` the caller cannot access SHALL produce the same not-found error text as a
nonexistent one.
- **N6.** An `answers` array SHALL be rejected with a validation error, and SHALL NOT reach the
claim, when **any** of these four conditions holds. The list is exhaustive:
1. It does not contain exactly one entry per question in the bundle.
2. An entry carries an **empty** `question_id`.
3. An entry references a `question_id` not in the bundle's expected set.
4. An entry repeats a `question_id` already used by an earlier entry.
All four are the existing rule in `validateRespondAnswers`.
- **N6a.** The expected question-id set SHALL be derived from the bundle's durable messages, counting
**one expected answer per `clarification_request` message** in the bundle. Today
`validateRespondAnswers` falls back to permissive acceptance when it cannot determine that set;
under this spec step 1 has already proven the messages exist and L16 has already excluded every
bundle whose ids are empty, so the expected set is always non-empty and that permissive branch
SHALL NOT be reachable from `ResolveBundle`.
- **N7.** An answer entry MAY carry `selected_options` (option IDs), `custom_text`, or both. An entry
carrying neither SHALL be accepted and SHALL render as "(no answer)" in the resume prompt,
preserving `formatAnswerBody`.
- **N8.** A `selected_options` entry naming an `option_id` not present on that question SHALL be
rejected with a validation error and SHALL NOT reach the claim. *(This is stricter than today's
REST endpoint, which does not check option IDs. External agents fabricate identifiers; the human at
the keyboard clicks a rendered button. The check is applied on both surfaces so the two cannot
drift.)* N8 constrains **membership only**. It does not constrain cardinality: `selected_options` is
a slice and nothing in the existing model marks a question single- or multi-select, so an entry
naming several valid option IDs SHALL be accepted. Inventing a single-select rule here would reject
answers the overlay itself can produce.
- **N8a.** A request carrying both `rejected: true` and a non-empty `answers` array SHALL be rejected
with a validation error and SHALL NOT reach the claim. The two are mutually exclusive outcomes and
guessing which the caller meant would silently discard one of them. A request carrying
`rejected: false` with an empty `answers` array is the N6 count-mismatch case and is likewise
rejected, **including for a single-question bundle**: N7 governs an answer *entry* that carries
neither field, and an empty array contains no entry at all. A caller that means "no answer" for a
one-question bundle sends one entry with neither field (N7); a caller that means "I decline" sends
`rejected: true`.
- **N8b.** `reason` SHALL be capped at **2000 runes**; a longer value SHALL be rejected with a
validation error rather than truncated, since the reason is replayed verbatim into the blocked
agent's resume prompt. `custom_text` on an answer SHALL be capped at the same limit, per entry. The
cap is enforced inside `ResolveBundle`, so it binds the REST endpoint and the web overlay as well
as this tool (W4a adds the matching client-side guard). The limit counts UTF-8 **runes**, not
bytes, so a non-Latin answer is not cut short at a third of the visible length.
- **N8c.** Validation (N6, N6a, N7, N8, N8a, N8b, L16) SHALL run **before** the claim in step 4, so a
malformed request never resolves a bundle, and before R2's loser branch (R2c). A validation error
SHALL leave the bundle untouched.
- **N8d. Validation order among the rules is unspecified and deliberately so.** When one submission
violates several, any one of them MAY be reported; R10 requires only a 400 whose message names the
offending field. Pinning an order would constrain the error string without changing any state
outcome — every ordering rejects the same set of submissions, writes nothing, and leaves the bundle
answerable. A test SHALL therefore assert the 400 and the named field for each rule in isolation,
never a particular winner among simultaneous violations.
- **N9. Argument schema.** Both tools SHALL declare their arguments using the same
`mcp.NewTool` / `mcp.WithString` / `mcp.WithBoolean` / `mcp.WithNumber` / `mcp.WithArray` /
`mcp.WithObject` helpers every other tool on this surface uses, with `mcp.Required()` on
`pending_id` and on nothing else. `answers` is an array of objects whose shape mirrors
`ask_user_question_kandev`'s own nested `questions[].options[]` declaration, which is the in-repo
precedent for an array-of-objects argument. A raw JSON Schema via `NewToolWithRawSchema` is
permitted where the helpers cannot express the nesting. No new schema convention is introduced.
### Surface placement
- **S1.** Both tools SHALL be registered for `SurfaceExternal` only.
- **S2.** Neither tool SHALL appear on `SurfaceKanbanTask`, `SurfaceOfficeTask`, or
`SurfaceConfiguration`. In-session MCP scoping resolves to the workspace **owner**
(`internal/mcp/scope`), not to a task relationship, so a running agent on the kanban surface would
be able to list and answer human questions across every task that owner can see. That defeats the
human-input boundary and collides with autopilot's parent-only interaction model
(`ask_parent_question_kandev`).
- **S3.** `ask_user_question_kandev` SHALL remain absent from the external surface, as today.
- **S4.** Neither tool SHALL be added to the session agent system prompt, since neither is visible to
session agents.
### `list_tasks_kandev` enrichment
- **T1.** `list_tasks_kandev` SHALL include `task_pending_action` and `primary_session_pending_action`
for each task, using the same projection the HTTP task list already returns
(`GetPendingActionsForSessions`, `task/dto/dto.go`), so one call finds every blocked task in a
workflow.
- **T2.** A task with no blocked session SHALL carry JSON `null` for both fields rather than an empty
string, matching the HTTP DTO — both fields are `*string` with no `omitempty`, so the key is always
present and its value is `null`.
### Catalog and documentation
- **C1.** `apps/web/lib/settings/external-mcp-tools.ts` SHALL list both new tools with localized
descriptions, in a group whose title reflects answering agent questions.
- **C2.** The catalog's KNOWN DRIFT SHALL be closed in the same change. The backend's external tool
count and the catalog's pinned count SHALL be re-derived against the post-rebase `main` at
implementation time and made equal; the stale drift note in both
`external-mcp-tools.ts` and `external-mcp-tools.test.ts` SHALL be **deleted rather than
renumbered**. The first version of this spec pinned the arithmetic at 35 → 37 and 30 → 37 against
the pre-rebase merge base; upstream has since landed 60+ commits, so those literals are recorded
here as **provenance, not as targets**, and a builder SHALL read the live values from
`TestServerModeExternal_ToolCount` and `countExternalMcpTools()` instead of trusting them.
- **C3.** Every new catalog entry SHALL resolve to an existing `en/settings.json` key, per the
existing pinning test.
## Permissions
This spec introduces no new permission concept. It applies the existing per-user workspace scoping
rule (`apps/backend/AGENTS.md`, "Opt-in authentication & per-user scoping") to a service that missed
it:
- No identity in context, or a synthetic identity → unscoped, today's pre-auth behavior.
- Real identity → the bundle is visible if the workspace owning its task has an empty `owner_id` or
an `owner_id` matching the caller, **and also** in the two further cases `authorizeTaskID` itself
allows: the task has no workspace, or the task's workspace row does not exist. **L1c is the
normative statement** and the list tool's query predicate SHALL be written against it.
- Denial uses the not-found sentinel (`repoerrors.ErrTaskNotFound` via `AuthorizeTaskAccess`), so a
foreign bundle and a missing bundle are indistinguishable.
The authorization input is the `pending_id` → `task_id` mapping read from the durable messages (M5).
It SHALL NOT be read from a caller-supplied `task_id`; a caller that supplies one alongside a
`pending_id` has it ignored.
External MCP callers reach this check because `DispatcherBackendClient.RequestPayload` passes the
HTTP request context — carrying the identity the auth middleware attached — straight into `Dispatch`.
**An earlier proposal to authorize via the MCP scope resolver was wrong.** `internal/mcp/scope`
attaches the owning identity of an in-session agent stream; it neither resolves a `pending_id` nor
authorizes one, and it does not apply to the external endpoint at all.
## Failure modes
- **Two callers answer the same active bundle.** Upstream's claim admits one. The winner runs step 5;
the loser takes R2's winner branch and receives the winner's status and reconstructed answers with
`claimed: false`. No transcript overwrite, no second resume.
- **A caller answers a bundle whose turn has been superseded.** The claim's current-turn predicate
rejects it, no message carries a terminal status, and R2's second branch returns 409. This is a
deliberate narrowing relative to the first version of this spec, which would have answered it and
resumed the agent in a new turn; upstream's approved contract states superseded rows "cannot drive
... a late agent resume", and this spec defers to it rather than reopening that decision.
- **A caller answers a bundle whose session has reached a terminal state.** Same path, same 409. The
first version of this spec explicitly allowed this ("the transcript is a record, not a live
channel"); that promise is withdrawn for the same reason.
- **A caller answers a detached bundle on the current turn.** Detachment sets `agent_disconnected`
without changing `status`, so the bundle is still active: the claim succeeds and upstream's
bounded-wait detached-resume dispatch runs. This is the "the agent moved on but the answer still
matters" case, and it still works.
- **A backend restart strands a bundle.** A restart does not by itself end a turn or terminate a
session, so the bundle remains active, is still listed by L1, and is still answerable (R6).
- **The resume dispatch cannot be accepted.** Upstream rolls the claim back via
`RestoreActiveClarificationBundle` and the endpoint returns a server error; the bundle becomes
answerable again. This spec adds nothing here and deliberately reports no partial-success state.
- **A bundle's messages carry no `question_id`.** L16 excludes it from L1 and rejects it before the
claim, because upstream's claim would abort mid-transaction. It is visible in the transcript and
clearable only by whatever upstream's lifecycle does to it (expiry on session teardown, or
supersession by a newer turn).
- **A bundle's `task_id` resolves from neither source.** M5a: not-found for scoped callers, omitted
from L1 for every caller. Answerable by an unscoped caller whenever upstream's claim still accepts
it. Near-degenerate in practice, since `task_session_messages.task_session_id` is FK-constrained.
- **A bundle's session row is deleted.** The session cascade removes the bundle's messages, so step 1
finds nothing and the answer returns not-found (A5) — the same response as an unauthorized bundle,
by design. Deleting a task deletes its sessions, so this is also the deleted-task path.
- **The loser's re-read races another transition.** R2a: one read, no retry, report what was seen.
## Scenarios
1. **External agent answers a live question.** Agent A on task T calls `ask_user_question_kandev` and
blocks. An external client lists pending questions, sees T's bundle with its option IDs, and calls
`answer_question_kandev`. The claim commits, the in-memory waiter is delivered, and agent A's
blocked tool call returns the answers **in the same turn**. The chat overlay closes for anyone
watching, via the existing `session.message.updated` broadcast.
2. **Human and external agent answer simultaneously.** The human clicks Submit while the external
client posts a different answer. One claim wins. The winner's answers reach agent A and appear in
the transcript. The loser gets a 200 with `claimed: false` and the winner's payload; the losing tab
renders the winner's answers, not its own (W3); no second turn starts, no transcript overwrite.
**This is the race no test in the tree can currently exercise**, because the MCP answerer did not
exist when upstream's tests were written (R3).
3. **Answer after a backend restart.** A bundle is created and the backend restarts, so the in-memory
entry is gone. An external client lists pending questions — the bundle is still there, because the
list reads durable messages (L2) and the restart did not supersede its turn. It answers; there is
no live waiter, so upstream's detached-resume dispatch resumes the agent. A second answerer gets
R2's winner branch rather than a second resume.
4. **Foreign bundle.** User B holds a PAT and learns a `pending_id` belonging to user A's workspace.
`answer_question_kandev` returns not-found, identical to a fabricated ID.
`list_pending_questions_kandev` never showed it. `GET /:id` and `GET /:id/wait` both return 404
rather than their usual 404/504 split, because A7 puts the authorization check ahead of the
in-memory read.
5. **Superseded bundle.** The session accepted a newer turn while an older clarification was still
pending. The bundle is absent from `list_pending_questions_kandev`, does not block turn-complete,
and an answer attempt returns 409 naming it as no longer active. The transcript still shows it.
6. **Auth disabled.** Identity is synthetic and every caller is unscoped, so nothing is ever denied:
every bundle remains listable and answerable exactly as before. The six behaviors that do change
are enumerated in A4 and change identically under enforced auth. There is one code path.
## Retired criteria
Five rounds of spec review and six Build rounds referenced the identities below. Each is withdrawn
here, with its reason, so a reader of that history can tell **retired** from **missing**. No retired
rule may be reintroduced without reopening the collision this revision resolves.
- **M1, M2, M3, M4, M6, M6a, M7, M7a, M8, M8a, M9, M10** — the entire `clarification_resolutions`
table: its schema, FK cascade, no-backfill rule, dialect portability, response payload shape,
`resume` write ordering, the `ON CONFLICT ... DO NOTHING` claim statement, its FK-violation and
vanished-conflict-row edge cases, and the `source` column. **Retired: the table is not created.**
Upstream's `CompleteActiveClarificationBundle` is the claim (P1). M6a's substance survives as
**R12**, because the `omitempty` problem it identified is a property of `clarification.Response`,
not of the table.
- **M5b** — the answerability split between M5a's two arms, which depended on M8a's foreign key.
**Retired with the FK.**
- **D4/D4a's two-conjunct membership rule** — "no resolution row AND at least one effectively-pending
message". **Retired: conjunct 1 named a row that no longer exists.** D4 now defers to upstream's
activeness rule outright, and D4a records why no migration is needed.
- **G1, G2, G3, G4, G5** — the workflow-guard changes. **Retired: the guard is already correct.**
Upstream repointed `sessionHasPendingClarification` at `FindActiveClarificationMessagesBySessionID`,
which already widens `= 'pending'` to `COALESCE(status,'') IN ('','pending')` and adds current-turn
scoping. The function G1/G4 proposed to modify no longer exists. **G5 is additionally withdrawn on
the merits**, not merely as dead code: it required an *unrecognized* status to count as pending
(fail closed), while upstream's shared predicate counts it as not-pending. Two consumers disagreeing
about membership is precisely the defect G5 was written to prevent, so this spec adopts upstream's
rule rather than diverging from it (D3).
- **R5, R5a** — partial application of per-question updates, the stop-at-first-failure prefix, and
the 500 that reported it. **Retired: partial application is now impossible.** Upstream's claim
writes every row of the bundle in one transaction, so a half-applied bundle cannot be produced.
The corresponding R10 row and A4 item are gone.
- **R8, R8a, R8b, R8c** — the four-valued `resume` field and its rule ordering. **Retired: there is
no success case in which the resume is in doubt.** Upstream's respond path returns success only
after delivery confirmation or accepted dispatch, and rolls back otherwise (R7a).
- **R9a** — marking a bundle's messages `rejected` when they carry no `question_id`. **Retired:**
upstream's claim refuses such a bundle entirely, so the rejection cannot land at all. L16 now
excludes the bundle from discovery instead of promising an outcome that would 500.
- **X1, X2, X3, X3a, X4, X5** — routing `POST /:id/cancel` through the claim with
`status = cancelled`, the losing-cancel `CancelCh` exemption, and the four-row cancel status table.
**Retired: upstream's claim has no `cancelled` terminal status** — `CompleteActiveClarificationBundle`
rejects any status other than `answered` or `rejected` — and adding one is a change to the
active-lifecycle contract, not to this card. Cancel keeps its existing behavior and gains **only**
the A2 authorization check. See *Out of scope*.
- **W3a's union widening** — adding `"cancelled"` to the client's `ResolvedStatus` union.
**Retired: the backend can no longer return a cancelled winner** (W3a).
- **C2's literal counts (35 → 37, 30 → 37)** — **retired as targets, kept as provenance.** Upstream
landed 60+ commits after those numbers were measured. C2 now requires re-deriving them.
## Out of scope
Each exclusion below is a decision, not an omission.
- **Answering a superseded or terminal-session bundle.** Upstream's approved active-lifecycle spec
states superseded rows cannot drive a late agent resume, and that a terminal session's pending
bundles expire. The first version of this spec promised the opposite. **That promise is withdrawn
rather than fought**: upstream's spec is approved and merged, this one is draft and unmerged, and
two contracts disagreeing about when a clarification is answerable is exactly the defect this
revision exists to remove. Anyone who wants stale-bundle answering back should change the
active-lifecycle contract, on its own card, with its own threat model.
- **Making cancel atomic, authorized-and-claimed, or usable on a restart-stranded bundle.** Cancel
gains A2's authorization check and nothing else. Its existing defects — it requires a live
in-memory entry, it applies per-question updates in a log-and-continue loop, and it cannot clear a
bundle whose entry is gone — are untouched here because fixing them needs a `cancelled` terminal
status in `CompleteActiveClarificationBundle`, which is upstream's primitive and upstream's
contract. Adding a second cancel-claim in this card would violate P1 outright.
- **Resolving a bundle whose messages carry no `question_id`.** L16 excludes it. Making it resolvable
requires upstream's claim to tolerate an empty `question_id`, which is a change to a shared
primitive with its own lifecycle questions.
- **Storing the winner's `reject_reason` durably** so a loser can replay it. R2b reports `""`.
Repairing it means adding a metadata key to a shape the *Upstream baseline* freezes.
- **`wait_for_question_kandev` / long-poll / push.** Deliberately dropped. It recreates the long-held
MCP connection that `ask_user_question_kandev` already papers over with progress pings — justified
for an agent blocked on its own question, wrong for a discovery API. Callers poll the list tool
with a cursor. Notification is reconsidered only alongside a real subscription contract with
reconnect and missed-event recovery; progress pings are not that.
- **A grand total in the list response.** L11a. `next_cursor` answers "is there more".
- **Extending the two tools to the in-session task surface.** Requires its own threat model (S2).
- **The clarification popup's own UX** — Escape committing a skip rather than dismissing, shortcuts
focus-scoped to the overlay, submit gated on the whole bundle. Separate card. W4 is in scope only
because N8b makes an over-long answer newly rejectable.
- **The Office inbox workspace leak.** `DashboardService.inboxPermissionItems` calls
`Store.ListPendingPermissions()` with no workspace argument while every sibling call in the same
function takes `wsID`. **Verified during this spec's input inventory**, not merely suspected:
pending clarifications from every workspace appear in every workspace's inbox.
`ListPendingPermissions` is also restart-lossy and option-less. It is untouched here, and this
spec's tools deliberately do not reuse it. It needs its own card.
- **Changing `ask_user_question_kandev`'s own shape, validation, or response envelope.** Unchanged.
- **A `pending_id`-aware WS gateway backstop entry.** `Client.authorizeAction` keys off
`task_id` / `session_id` / `id`. The new actions are reachable only through the external MCP
dispatcher, which does not go through the gateway client, and they authorize at the service layer.
If these actions are ever exposed over the browser WS, the backstop must learn `pending_id` first.
## Verification notes
**E2E decision.** This touches one user-visible surface: the clarification overlay in chat, via
W1–W4a. `apps/web/e2e/tests/chat/clarification.spec.ts` already covers the overlay end to end and
already intercepts `**/api/v1/clarification/*/respond`. Extend it with a duplicate-submit case
asserting the overlay closes on a `claimed: false` **200** and that the rendered answer is the
winner's, not the loser's (W3); do not add a new spec file. The two MCP tools have no browser surface
and need no E2E.
**Assertions that must exist.**
- **R3's cross-entry-point race is the single most important new test in this revision.** It needs a
real two-goroutine test against a shared database in which one caller answers over the REST handler
and the other over `answer_question_kandev`, asserting exactly one observes `claimed == true` and
the other receives the winner's payload. A REST-vs-REST or MCP-vs-MCP race does **not** substitute:
the collision this revision resolves was precisely two entry points claiming by different
mechanisms, and only a cross-entry-point test proves P1 holds.
- **P1 needs a structural assertion, not only a behavioral one.** Assert that no table named
`clarification_resolutions` is created by `initSchema` or any migration, and that the resolution
path contains no claim statement of its own. A behavioral test passes just as well with a dormant
second mechanism present.
- R2's two branches need separate tests: a duplicate answer against a bundle with a terminal status
(expect 200, `claimed: false`, winner's payload) and an answer against a superseded bundle (expect
409). A test that only covers the first would pass with the branches collapsed, which is the exact
defect R2 exists to prevent.
- R2b needs a test that a replayed **rejection** carries `answers: []` and `reject_reason: ""`, and a
test that a replayed **answer** carries the winner's answers in D2 order — not the loser's.
- The authorization tests (A1–A5a, A7, A8) need both the enabled and disabled auth modes, since A4 is
the compatibility guarantee. The auth-disabled run asserts all **six** A4 carve-outs are present,
not absent.
- A5a additionally needs an auth-**enabled** test asserting that `GET /:id/wait` returns the **same**
status for a foreign bundle and for a fabricated `pending_id`. Asserting only the 404 on the
fabricated id would pass even if the foreign case returned something else, which is exactly the
oracle A5a exists to close.
- R12 needs a test asserting the response envelope contains the **keys** `answers`, `rejected` and
`reject_reason` on every outcome — on a rejection `answers` is `[]` and present, on an answer
`rejected` is `false` and present. Assert key *presence* explicitly (`json.RawMessage` / map
lookup), not the unmarshalled Go value: unmarshalling an absent key yields the same zero value as
an emitted empty one, so a test that only reads the struct back passes against the exact bug R12
exists to prevent.
- L16 needs a test that a bundle with an empty `question_id` on any message is absent from
`list_pending_questions_kandev` **and** that answering it returns the pre-claim validation error
rather than a 500 from upstream's aborted transaction.
- L2a needs a test that a superseded bundle and a terminal-session bundle are both absent from the
list, proving the tool uses upstream's activeness predicate rather than a status-only filter.
- L1a needs a test with more matching bundles than `limit` across two workspaces, asserting the page
is full rather than short.
- N3a needs a test that two differently-ordered but semantically identical submissions produce
byte-identical answer payloads, and one proving N3a rule 3's ordering: a `custom_text` of exactly
2001 runes with a trailing space is **rejected**, not trimmed to 2000 and accepted.
- N8b needs both boundary tests: exactly 2000 runes accepted, 2001 rejected, counted over code points
with a multi-byte fixture.
- N8d needs per-rule isolation tests only. A test asserting which error wins among simultaneous
violations SHALL NOT be written; it would pin an unspecified behavior.
**Tests that need deliberate re-derivation against upstream.** The first version of this spec was
built and reviewed across six Build rounds. The following existing artifacts test the retired
mechanism and SHALL be re-derived or deleted rather than rebased: `internal/clarification/resolver_test.go`
and `resolver_restart_test.go`'s claim assertions, `internal/task/repository/sqlite/clarification_resolution_test.go`
and its Postgres sibling in full, and the `clarification_bundle_query_*` tests' D4a-conjunct-1 arms.
`handlers_authz_test.go`, `outcome_validation_test.go`, `question_handlers_test.go`, and
`task_pending_actions_enrich_test.go` test criteria this revision keeps and should largely survive.
**Tests that need deliberate updating.**
- `internal/mcp/server/server_test.go` — `TestServerModeExternal_ToolCount` moves to the re-derived
post-rebase count (C2).
- `internal/mcp/server/external_integration_test.go` — its `NotContains "ask_user_question_kandev"`
assertion stays **true and unchanged** (S3); add positive assertions for the two new tool names.
- `apps/web/lib/settings/external-mcp-tools.test.ts` — the pinned count moves to the re-derived value
and the stale drift note is deleted (C2).
- Any test asserting a **409** from `POST /:id/respond` on a duplicate submit moves to 200 with
`claimed: false`; tests asserting 409 for a genuinely inactive bundle stay (R11).
- `internal/mcp/handlers/handler_inventory_test.go` measures the dispatcher delta dynamically and
needs **no** change when handlers are added.