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>
32 KiB
Current-turn authority: a lifecycle turn must never shadow the real turn
Status: spec
Area: backend (task repository, task service, agent lifecycle), frontend (chat overlay), protocol
Slug: current-turn-authority
Amends: ADR-2026-08-14 Current Turn Owns Active Clarification
Why
A pending ask_user_question_kandev clarification became invisible to every surface that exists to
show it: list_pending_questions_kandev omitted it, the task-list badge cleared, the chat overlay
stopped rendering it, and the Stall Session Watchdog could not tell the session from one with no
open turn. Task 4be500f5 sat on an unanswered architectural question for 70+ minutes, found only
because a human read the raw transcript. The cause is not a race and not data corruption, but a
stated invariant enforced in exactly one of eleven places.
Service.createCompletedTurn (apps/backend/internal/task/service/service_turns.go:274) documents
its own contract:
persists a synthetic turn that is never observable as active. It is used for lifecycle messages that must belong to a turn without making an idle task appear to have an active agent turn.
That turn is created on every agent resume launch: bootMsgAdapter.CreateMessage
(apps/backend/internal/backendapp/worktree.go:57) passes CompletedTurn: isResuming for the
agent_boot script_execution message. It is a real task_session_turns row with
started_at = completed_at = created_at = now, ordinary metadata, and one message.
Only GetActiveTurnBySessionID honours the invariant, by filtering completed_at IS NULL. Every
other consumer resolves "the session's current turn" as
ORDER BY turn_row.started_at DESC, turn_row.created_at DESC, turn_row.id DESC LIMIT 1
gated by turnAuthorityPredicate. The synthetic turn passes that predicate on its first
disjunct alone (NOT (prompt_dispatch_pending) is true; it never needs the message-EXISTS
fallback), and its started_at is later. So it wins, and every clarification owned by the real,
still-open turn is reclassified as superseded history.
Evidence
In session adddd3fc the real turn 6d91a053 opened at 01:40:08.937 and the resuming boot script
wrote a synthetic turn 0.8s later, which became "current". The clarification_request raised on
6d91a053 at 02:44 stayed invisible on every surface until 04:08, when that turn completed and the
question became legitimately superseded history. It repeated minutes later: the ordinary resume path,
not a one-off.
Across 6,133 production turns, 775 have completed_at = started_at — but 158 carry more than one
message (real turns buried by AbandonTurn) and 27 hold clarification_request rows, which is
why D3 forbids inferring "synthetic" from that shape. Shadowing has occurred 207 times across 116
sessions, leaving 7 pending clarifications across 5 sessions unreachable today.
Prior art
Our own prior reasoning
The in-repo prior reasoning already answered the design fork. ADR-2026-08-14 (accepted, amended 2026-08-15) decided:
A pending clarification is operational only when its message belongs to the session's newest durable
task_session_turnsrecord.
It further requires that one repository derivation supply the clarification guard, detach/expiry fallback, response validation, per-session pending projection and task-summary reconciliation, and that both dialects select the same turn under dialect-sensitive tests (D9). It explicitly rejected deriving ownership from the latest surviving message, since deleting a newer turn's last message would roll the boundary backward and reactivate older pending history. This spec does not reopen that: turn identity stays the boundary.
Where this spec departs. "Newest durable record" assumes every durable turn represents
conversational work. createCompletedTurn predates the ADR and creates durable records representing
no work at all, whose own doc comment says they must never be treated as active. The two intents
agree; only the word "durable" fails to separate them. This spec narrows it to "newest durable
conversational record" (R1) and adds one ordering rule (R2) — which requires amending the
ADR (AC-12), not a silent divergence.
Its "one repository derivation" requirement is also only partly honoured today: the ten call
sites share turnAuthorityPredicate but hand-write their own ordering, and the frontend hand-writes
a third copy. AC-3 and R3 close that.
Also read and honoured, both deferring to the same upstream rule rather than restating it:
external-question-answering/spec.md (D4, D4a, L2a) and
tasks/system-design/clarification-active-lifecycle.md, both under docs/specs/.
What other products shipped
Queried the saas-kb corpus for agent-question surfacing and session-resume semantics: every hit
(Warp, OpenHands) was end-user documentation that the feature exists, none documenting turn identity
or resume semantics — the layer this defect lives in. Nothing there makes a durable, queryable
"which question is still answerable" the contract, as Kandev already does.
Terminology
- Turn record — a row in
task_session_turns. - Lifecycle turn — a turn created solely to give a lifecycle message a parent, by
Service.createCompletedTurn. It represents no agent work and is born already completed. - Conversational turn — any turn record that is not a lifecycle turn.
- Current turn — the single conversational turn owning a session's active state, as resolved by R1 + R2 below. May be absent.
- Authority-eligible — a turn record that passes
turnAuthorityPredicate(the existing reservation filter) and is conversational. - Shadowing — a turn that is not the intended current turn being resolved as the current turn.
Data model
No schema change. No migration. No backfill.
One new turn-metadata key, written at insert time:
| key | value | written by |
|---|---|---|
lifecycle_only |
JSON true |
Service.createCompletedTurn, on every turn it creates |
Declared as models.TurnMetaKeyLifecycleOnly = "lifecycle_only" beside the existing TurnMetaKey*
constants in internal/task/models/models.go, and read with turnMetadataFlagPredicate so both
dialects normalise it identically (D12), exactly as prompt_dispatch_pending already does.
models.Turn already serialises metadata and the TypeScript Turn type already declares
metadata?: Record<string, unknown>, so the marker reaches the frontend with no DTO change.
Determinism and boundary rules
-
D1. The current-turn resolution is a total order over authority-eligible turn records, keyed in this exact sequence, all named columns:
CASE WHEN completed_at IS NULL THEN 0 ELSE 1 ENDascending (open before completed)started_atdescendingcreated_atdescendingiddescending
Key 4 is a primary key, so the order is total and no tie is broken by chance. Keys 2–4 are already in the code; key 1 is the only addition, and it is what stops a zero-duration completed turn outranking a genuinely open turn.
-
D2. Key 1 is safe only under the single-open-turn invariant, namely: a session has at most one turn with
completed_at IS NULL, and no open turn survives a session resume.AbandonOpenTurnsalready enforces the second half on the resume path, and the first half holds across all 6,133 production turn records (zero sessions with more than one open turn). Key 1 changes the resolved turn only when the newest record is completed while an older record is still open — which under this invariant can only be the shadowing defect itself. It is neither assumed silently nor claimed as a guarantee: AC-8 requires the enforced half tested and the observed half to have a defined, tested resolution when violated. -
D3. "Lifecycle" is a durable marker, never an inference from
completed_at = started_at. That equality is produced by two unrelated, both-legitimate paths:createCompletedTurn(a lifecycle turn) andRepository.AbandonTurn(a real turn buried after a crash, so its dead hours do not poison analytics). They are indistinguishable by shape: 158 zero-duration turns carry multi-message real conversations and 27 carryclarification_requestrows, which any rule keyed on zero duration would silence permanently. A hard prohibition, not a preference. -
D4. An absent
lifecycle_onlykey means conversational. Every turn record written before this change is therefore conversational, including the 775 existing zero-duration rows. Deliberate; see Known residual. -
D5. A lifecycle turn never holds a
clarification_request.createCompletedTurnis reachable only from the boot-message path; clarification rows are written throughgetOrStartTurn. AC-7 asserts this so R1's exclusion can never hide a question. -
D6. Resolution is a pure function of committed rows, taking no new lock and adding no write. Two concurrent readers of the same committed state resolve the same turn. A reader concurrent with an insert resolves against whichever snapshot its transaction sees; because lifecycle turns never compete for the current-turn slot, the resolved turn is the same whether or not a concurrent
createCompletedTurnhas committed. That independence is the point of the fix. -
D7. Marking is idempotent by construction.
createCompletedTurnwrites the marker in the sameINSERTas the row. A retried boot-message creation produces another marked lifecycle turn; N marked lifecycle turns are as excluded as one, so retry needs no dedupe. -
D8. Absent current turn is a legitimate result, not an error. A session with zero authority-eligible turns resolves to no current turn. Every consumer already handles a
LIMIT 1subquery returning no row (theturn_id = (…)join matches nothing), unchanged. A session whose only records are lifecycle turns now resolves to no current turn where it previously resolved to the lifecycle turn — correct, since it has had no conversational turn and holds no active state. -
D9. SQLite and PostgreSQL must resolve identically, including the
CASE WHEN completed_at IS NULLkey and the metadata-flag normalisation. Restated from the ADR because key 1 is new and untested in both dialects. -
D10. "Current turn" and "active turn" are different questions and must not be merged.
GetActiveTurnBySessionIDanswers "is a turn running right now" and filterscompleted_at IS NULL; the other nine sites answer "which turn owns this session's state" and do not. Adopting AC-3's shared helper SHALL NOT remove that filter, or the function begins returning completed turns andAbandonOpenTurnsburns its 16-iteration budget re-burying them. Because a lifecycle turn is always born completed, R1's exclusion is a no-op here, applied for uniformity, not effect. -
D11. Both fragments must compose into the four SQL shapes already in the code, changing nothing else at any site: the scalar subquery (
clarificationBundleQueryplus the six inmessage_clarification_response.go), thecurrent_turnCTE (FindActiveClarificationMessagesBySessionID), theROW_NUMBER() OVER (PARTITION BY task_session_id ORDER BY …)window (pendingActionsBySessionQuery), and the top-levelORDER BY … LIMIT 1(GetActiveTurnBySessionID, which keeps its owncompleted_at IS NULLper D10).predicatejoins each site's existingWHERE;orderByreplaces each site's ordering. HenceorderByis a bare expression list, not a canned subquery, which would fit only the first shape. The window site resolves many sessions in one statement; a helper forcing it back to N scalar subqueries is a performance regression on the task list and SHALL NOT be accepted. QualifyingorderBywithturnAliasis safe at the window site, whose current text is unqualified, because the alias is in scope. -
D12. A NULL or empty
metadatacolumn normalises to conversational. The column isTEXT DEFAULT '{}'andturnMetadataFlagPredicatealready wraps its extraction inCOALESCE(…, '')before theIN ('true','1')test, soNULL,'','{}'and malformed JSON all yield "not a lifecycle turn" — the same result D4 requires for an absent key, via the same existing helper. No new normalisation is introduced and none SHALL be added. -
D13. Clock skew and future timestamps change nothing. D1 keys 2 and 3 are compared as stored, unclamped: a turn whose
started_atis in the future sorts first within its open/completed class, exactly as today, and key 4 keeps the order total regardless. This spec deliberately adds no clock validation — the defect is not a timestamp defect, andstarted_atordering is already load-bearing inturnHistoryPredicate, the turns endpoint, and the frontend comparison. -
D14. Bundle claim, winner/loser, and conflict semantics are unchanged. This spec changes only which turn the claim predicate in
claimActiveClarificationBundlecompares against. Exactly one caller still claims a bundle, a loser still receives conflict with no write and no resume, and the restore-on-failed-delivery path is untouched. Those rules stay owned bydocs/specs/external-question-answering/spec.mdand ADR-2026-08-14, named here so their absence below reads as deferral, not silence. -
D15. The frontend rule is a deliberate subset, sound only via the history pre-filter. The backend resolves
turnAuthorityPredicate AND NOT lifecycle;newestDurableTurnIdimplements D1's ordering and D4's marker and no part of the authority predicate. That holds because every turn it sees arrives from the turns endpoint, already gated byturnHistoryPredicate. The predicates differ: authority also admits a dispatch-pending, dispatch-attempted, message-less turn, which history excludes, so such a turn can be current on the backend and never reach the frontend. That gap is accepted, not accidental — such a turn has no message and so no clarification to hide — and it is why AC-10's fixture is confined to authority-eligible turns: a fixture fed straight to both implementations bypasses the pre-filter the frontend relies on, and would assert an agreement neither implementation claims.
What
Each criterion is observable through the database, HTTP API, MCP tool surface, or web UI.
R1 — a lifecycle turn is never current-turn authority
-
AC-1. When
Service.createCompletedTurnpersists a turn, the system SHALL writemetadata.lifecycle_only = truein the same insert as the turn row. -
AC-2. The system SHALL exclude every turn whose
lifecycle_onlyflag normalises to true from current-turn resolution, at every site resolving a session's current turn. Those sites are exactly:file site task/repository/sqlite/session.goGetActiveTurnBySessionIDtask/repository/sqlite/message.goFindActiveClarificationMessagesBySessionID;pendingActionsBySessionQuerytask/repository/sqlite/clarification_bundle_query.goclarificationBundleQuerytask/repository/sqlite/message_clarification_response.goDetachActiveClarificationMessagesBySessionID;ExpireActiveClarificationBundle;loadRestorableClarificationBundle;restoreClarificationMessages;claimActiveClarificationBundle;loadClaimedClarificationBundleA site added later that resolves a current turn without the shared builder is a defect against AC-3, not a gap in this list.
-
AC-3. The system SHALL express D1's ordering and R1's exclusion in one named Go helper in
internal/task/repository/sqlite/turn_authority.go:func currentTurnAuthority(driverName, turnAlias string) (predicate, orderBy string)Two fragments: the rule occupies two SQL positions and no one string sits in both.
predicateisturnAuthorityPredicateAND R1's exclusion (NOTthelifecycle_onlyflag, viaturnMetadataFlagPredicate) as one parenthesised conjunct, forWHERE … AND %s.orderByis D1's four keys as a bare expression list qualified byturnAlias, with noORDER BYkeyword and noLIMIT, so it drops unchanged into bothORDER BY %sandROW_NUMBER() OVER (… ORDER BY %s). Every AC-2 site SHALL take both from this one call and hand-write neither.turnAuthorityPredicateis today called at exactly those ten sites, so subsuming it affects no other consumer. This is the concrete form of the ADR's "one repository derivation" requirement. -
AC-4. While a session's real turn is open and a lifecycle turn exists with a later
started_at, the system SHALL resolve the current turn to the real turn, asserted by a Go integration test over the service and repository layers; all three consumers below are Go- reachable, so no browser-level test is required. Reproducing the sequence in Why: after the lifecycle turn is written,list_pending_questions_kandevSHALL return the bundle (through the MCP handler ininternal/mcp/server/question_handlers.go), the session'spending_actionSHALL beclarification(throughGetPendingActionsBySessionIDs), andPOST /api/v1/clarification/:pendingId/respondSHALL succeed rather than return conflict (throughinternal/clarification).The lifecycle turn SHALL come from the real write path —
Service.CreateMessagewithCompletedTurn: true— never an inserted row imitating it, so the test fails if AC-1's marker is dropped. Ordering SHALL be made deterministic by seeding the conversational turn with an explicit paststarted_at(the existingseedBundleTurnAtidiom), sincecreateCompletedTurnstampstime.Now().UTC()and is therefore strictly later on every run. Starting both turns at "now" and relying on them to differ SHALL NOT be accepted: on a coarse clock they tie onstarted_atand the assertion turns onid, which is unrelated to the behaviour tested. -
AC-7. On the boot-on-resume path, every
clarification_requestwritten by the open conversational turn SHALL keep that turn'sturn_id, asserted as a repository-level test over a fixture exercising the path. The broader claim — that noclarification_requestanywhere can carry a lifecycleturn_id— holds only becauseCreateMessageRequest.CompletedTurnis taggedjson:"-"(no API client can set it) and has one production caller, which writes ascript_executionmessage. Neither property is type-enforced, so AC-7 SHALL also assert the first: unmarshalling a body that setscompleted_turnSHALL leave the field false. A caller later setting it while writing aclarification_requestvoids the claim and is a defect here.
R2 — an open turn outranks a completed one
-
AC-5. The system SHALL order current-turn candidates by D1's four keys in that order, so a turn with
completed_at IS NULLis preferred over any completed turn regardless ofstarted_at, and ties within each class fall through tostarted_at,created_at,iddescending. -
AC-6. If a session has an open conversational turn and an unmarked legacy zero-duration turn with a later
started_at, then the system SHALL resolve the current turn to the open turn. This rescues the pre-existing 775 unmarked rows in the only case where invisibility is actively harmful; see Known residual for the case it does not rescue. -
AC-8. The system SHALL assert the two halves of D2 separately, because only one is a guarantee. That
AbandonOpenTurnsleaves no turn withcompleted_at IS NULLis enforced and SHALL be tested directly. "At most one open turn per session" is observed, not enforced — no unique or partial index constrains it — so the system SHALL instead assert the defined behaviour when it does not hold: given two open turns on one session, resolution SHALL pick the one winning D1 keys 2 through 4, deterministically and without error. A test asserting the invariant itself could only check the fixture it just built, and would pass while production drifted.
R3 — the frontend consumes the same rule
-
AC-9.
newestDurableTurnIdinapps/web/lib/utils/pending-clarification.tsSHALL apply the same two rules — skip lifecycle turns, prefer a turn with nocompleted_at— before its existingstarted_at/created_at/idcomparison, reading the marker from the turn payload rather than inferring anything from timestamps.It SHALL treat a turn as lifecycle only when
metadata.lifecycle_onlyis one of exactly four values: booleantrue, number1, string"true", string"1". Everything else — absent key,null,undefined,false,0,"","false","0","yes", any object or array — is conversational. A bare truthiness test SHALL NOT be used, nor aString(value)coercion: truthiness misreads"false","0","yes",{}and[]as lifecycle, coercion misreads["1"], and D12's backend predicate (… IN ('true','1')) treats none of them as lifecycle. A divergence hides a clarification the backend is serving — the symptom this spec exists to remove. AC-10's fixture SHALL pin these values.Its open-turn check SHALL be
turn.completed_at == null, treatingnullandundefinedalike: the payload type declarescompleted_at?: string, so an open turn arrives as an absent key while AC-10's fixture writesnull. When every turn is a lifecycle turn it SHALL returnnull, notundefined—nullmeans history loaded with no durable turn and hides all messages, whereasundefinedmeans history not loaded and falls back topendingAction, reopening the shadowing this spec removes. -
AC-10. The Go and TypeScript resolutions SHALL be validated against one shared fixture file, consumed by both a Go repository test and a TypeScript unit test. Two implementations of one rule are tolerable only while a single artifact proves they agree, so it is specified here.
Path:
apps/backend/internal/task/repository/sqlite/testdata/current_turn_resolution.json, under the Go package becausego:embedcannot reach outside its tree while the TypeScript test can reach it by relative path. Format: a JSON object with acasesarray, each case{ name, turns, expected_current_turn_id }and each turn{ id, started_at, created_at, completed_at, metadata }, timestamps RFC 3339 UTC andcompleted_at: nullwhen open.expected_current_turn_id: nullmeans no current turn resolves; the fixture SHALL carry at least one such case (D8, Scenario 5).Go SHALL read it with
os.ReadFile("testdata/current_turn_resolution.json")— undergo testthe working directory is the package directory — then seed each case and run the real query. TypeScript SHALL resolve the path from the test file viaimport.meta.url, not the process working directory, which under Vitest isapps/web. If the file is missing, unreadable or unparseable both tests SHALL fail and neither may skip, because a fixture that silently stops being read is indistinguishable from two implementations that agree. Both SHALL run every case; either may add its own but SHALL NOT filter the shared ones. The cases SHALL cover every value in AC-9's list: the four resolving as lifecycle, and ten conversational — AC-9 names eleven, but an absent key andundefinedare indistinguishable in JSON and SHALL be written once, as the absent key.The fixture describes turns of one session and carries no
task_session_idortask_id; both areNOT NULLunder a foreign key, so the Go test SHALL supply its own session and task and stamp every turn with them. No fixture turn SHALL set aprompt_dispatch_*key, so every fixture turn is authority-eligible by construction and the fixture exercises D1's ordering and D4's marker only — the scope D15 requires. The TypeScript loader SHALL mapcompleted_at: nullto an absent property on eachTurnandexpected_current_turn_id: nulltonull; the production payload type is not widened to admitnull. -
AC-11.
GET /api/v1/task-sessions/:sessionId/turnsSHALL includemetadata.lifecycle_onlyon a lifecycle turn in its response payload, so AC-9 has the input it needs.turnHistoryPredicateSHALL keep including lifecycle turns in visible history — the boot message stays grouped under its own turn in the transcript, unchanged.
R4 — the decision record
-
AC-12. The system SHALL amend
docs/decisions/2026-08-14-current-turn-clarification-ownership.mdso its stated rule reads "newest durable conversational turn record", names thelifecycle_onlymarker, and records D1's ordering including the open-turn key. It SHALL also correct that ADR's Consequences section, which restates the rule as "SQLite and PostgreSQL must select the same newest durable turn". The ADR is the only place the rule is written down as a decision, and leaving it saying "newest durable record" would reintroduce this defect the next time someone implements against it. -
AC-13. The system SHALL update
docs/specs/tasks/system-design/clarification-active-lifecycle.md, whose Data model section states "The newest authoritative durabletask_session_turnsrecord for the session identifies the current turn", whose API surface section orders visible history "matching the reverse ordering used to select the current turn", and whose Persistence guarantees section calls clarification state reconstructable from "the newest authoritative durable turn" — all three inaccurate under D1.
Failure modes
- A lifecycle turn is written without the marker (a future second producer): treated as
conversational, can shadow again. AC-1 writes the marker inside
createCompletedTurnrather than at its call sites, so every caller inherits it. - A new consumer hand-writes its own current-turn subquery: silent divergence. AC-3's shared helper is the only thing that makes it visible in review.
- Two open turns coexist: D1 keys 2 to 4 choose between them, the same answer today's rule gives. AC-8 requires that resolution tested, not assumed away.
- An unmarked legacy lifecycle turn is newest and the real turn is also completed: it stays current and the older question stays invisible. Not fixed; see Known residual.
metadata.lifecycle_onlypresent but outside{true, 1, "true", "1"}: normalised to "not a lifecycle turn" byturnMetadataFlagPredicate(D12) and AC-9's enumeration. No new rule.
Known residual
The 775 existing turn records carry no lifecycle_only marker and this spec adds no backfill.
D3 explains why no migration can safely identify them: their only shared observable shape,
completed_at = started_at, is also the shape of 158 real conversations and 27 turns holding
clarification_request rows a backfill would silence permanently — converting a bounded visibility
bug into unbounded data loss. Consequently:
- A legacy lifecycle turn can no longer shadow an open turn — R2/AC-6 covers that, the case where a live question is hidden from a human who could still answer it.
- A legacy lifecycle turn still shadows a completed turn. Concretely, 4 of the 7 currently unreachable pending clarifications (2026-08-10 to 2026-08-18) keep a legacy lifecycle turn as their resolved current turn while their own turn is closed. Under ADR-2026-08-14 a question whose turn closed with no successor is superseded history anyway, so their observable outcome matches the intended contract even though the path to it is wrong. Named so their persistence is not mistaken for an incomplete fix.
- The residual may shrink as eligible sessions expire; AC-1 prevents any new unmarked lifecycle turn.
Out of scope
Named exclusions are contracts. Silence would be a defect.
- The Stall Session Watchdog automation (
065b95a3-f69f-43dc-96e9-6fedbf6df021). Its STEP 3 hand-writesORDER BY started_at DESC LIMIT 1with no authority predicate and no tiebreakers, then testscompleted_at IS NULL, so a lifecycle turn makes a genuinely stalled session look like it has no open turn and the watchdog never nudges it. A real second symptom of the same root cause, but the prompt lives in theautomationstable, not version control, so its owner must update it — to D1's ordering, or at minimum "newest turn that is notlifecycle_only". - Task
35d108bb's turn-completion duplicate race. That concernsacquireTurnCompletionCriticalSectiondouble-dispatching workflowon_enteractions: duplicate dispatches, not duplicate turn rows. The extra row here is created deliberately, by name, on a documented path. Unrelated;35d108bblanding would change nothing here. - Backfilling or deleting the 775 existing unmarked lifecycle turns. Prohibited by D3, quantified in Known residual.
- Changing what
AbandonTurnwrites.completed_at = started_aton a buried orphan is deliberate analytics behaviour, untouched. - Removing the lifecycle turn concept, e.g. a nullable
turn_idon the boot message or attaching it to the real turn. A larger change to the message/turn foreign key and to transcript grouping, not needed to close this defect. - Replacing the frontend's independent resolution with a server-supplied
current_turn_idfield. A cleaner end state, but it changes the turns endpoint envelope. AC-9 + AC-10 close the drift risk at a fraction of the blast radius. A follow-up card, not this one. - The
turnHistoryPredicatevisible-history rule. Unchanged by AC-11. - Anything about permissions, authorization, or the visibility predicate in
clarificationBundleQuery. Untouched.
Scenarios
- Resume during an open turn, question asked afterwards. Real turn opens; a marked lifecycle turn is written 0.8s later; the agent asks on the real turn. The question stays listed, badged and answerable throughout. (AC-1, AC-2, AC-4)
- Resume between turns. Previous turn completes, a marked lifecycle turn is written, a new real turn opens 3s later; current turn is the new real turn, so the ordinary path is unchanged. (AC-5)
- Legacy unmarked lifecycle turn over an open turn. No marker,
completed_at = started_at, later than an open conversational turn; current turn is the open turn. (AC-6) - Legacy unmarked lifecycle turn over a completed turn. Same fixture, real turn closed; the legacy turn stays current and the older bundle stays superseded, asserting the residual so it cannot change silently. (Known residual)
- Session with only lifecycle turns. No current turn resolves, nothing is listed, no consumer errors. (D8)
- Both dialects. Scenarios 1-5 give identical resolved turn ids on SQLite and PostgreSQL. (D9)
- Frontend agreement. AC-10's fixture yields the same resolved turn id from the Go helper and
from
newestDurableTurnId. (AC-9, AC-10)
Verification notes
- The repository-level tests belong beside
clarification_bundle_query_test.go, whose header comment documents the old(started_at, created_at, id)ordering and must be updated for key 1. - AC-4's integration test proves the backend-owned surfaces recover (MCP listing,
pending_action, the respond endpoint). The chat overlay is frontend-owned, proven by AC-9 and AC-10, not AC-4. apps/backend/internal/orchestrator/clarification_guard.gois the most severe consumer: a shadowed clarification makessessionHasPendingClarificationreturn false, letting a workflow step auto-advance past an unanswered question. Worth an explicit test.
User-visible surfaces touched
The chat clarification overlay (pending-clarification.ts); the task-list pending badge via
pending_action; POST /api/v1/clarification/:pendingId/respond success vs. conflict; MCP
list_pending_questions_kandev and answer_question_kandev; workflow step auto-advance via the
clarification guard. No new UI copy, so no i18n catalogue work.