Files
kandev/docs/specs/tasks/external-id-idempotency/spec.md
T
2026-08-09 18:07:23 +01:00

60 KiB
Raw Blame History

status, created, updated, owner
status created updated owner
draft 2026-08-07 2026-08-08 nova28

Task external ID (create idempotency)

Why

An external system that creates Kandev tasks over the API has no way to ask "did I already create this one?". Every create is unconditionally a new task, so a webhook redelivery, a network timeout the caller retried, or a crash between "task created" and "I recorded the task ID" produces a duplicate task — with a duplicate worktree and, when start_agent is true, a duplicate agent burning tokens on work already in flight. Callers today compensate with fragile heuristics (scanning task titles for a prefix, treating an existing branch name as a witness that the task exists), which break the moment a title is edited or a branch is renamed.

What this feature is, and is not

It is durable duplicate suppression. A create carrying an external ID never produces a second task for that identity, and always returns the task that holds it — including after the caller crashed before recording the task ID. That is the unblock: the caller gets its task ID back and stops guessing.

It is not automated crash repair. Kandev does not detect, adopt, resume, or clean up a partially-created task, and callers MUST NOT automate doing so either. The reason is not implementation cost — it is that the information does not exist. A task whose creation has not finished is indistinguishable from one whose creation is still running: both are simply "not settled yet". Without a liveness signal — a lease, a heartbeat, an owner token — no observer can tell a crashed create from a slow one.

An earlier draft claimed a retry would never observe a half-created task, which required exactly that machinery: fencing tokens, compare-and-swap on every transition, a two-phase coordinator, and isolation of unsettled rows. It was reviewed and rejected as unbuildable. This spec does not reintroduce it, and therefore does not make the guarantee it would have bought.

What callers get instead is an honest flag.

creation_complete has exactly one meaning, in every outcome and on every surface: the creation of the returned task completed its required synchronous work. Nothing more. It says nothing about whether an agent is running, and nothing about whether any process is currently alive.

The diagnostic case is the specific tuple deduplicated: true + creation_complete: false — another create claimed this identity and had not finished when we looked; it may still be in progress. That combination is diagnostic, not actionable. The safe response is to proceed with the returned task ID, or to escalate to a human — never to release the identity and create again. See The one unsafe thing a caller can do.

No other tuple carries the "may still be running" caveat.

What

  • A task MAY carry an external ID: a caller-supplied string that identifies the entity in the caller's own system (a Jira key, a webhook delivery ID, a UUID the caller minted before its first attempt).
  • An external ID SHALL be held by at most one task per workspace.
  • A create carrying an external ID SHALL have exactly one of four outcomes, and the response SHALL make which one unambiguous:
    1. Created — no task held that identity; a new task was created and holds it.
    2. Found, settled — a task already holds it and its creation finished.
    3. Found, unsettled — a task already holds it and its creation had not finished at observation time. It may still be running.
    4. Created, identity lost — this request created the task and finished its work, but another actor released the identity in the interim, so the task survives holding no external ID. Rare; see Settlement.
  • Both Found outcomes SHALL have no side effects: no new task row, no new agent session, no agent launch, no repository attachment, no attachment claim, no workspace-policy write, no branch creation, and no task.created event.
    • One bounded exception, on any concurrent-loser path. When a Found outcome is resolved by the step-3 lookup — which is every ordinary retry — nothing whatsoever is consumed. When it is instead resolved after a step-3 miss, the loser may already have allocated an office task-identifier sequence number, and that number is not returned to the pool. This applies to both late-resolution paths, because identifier allocation precedes both of them: the unique-index backstop, and the pre-insert re-read that catches an admission or capacity failure. This is the single permitted side effect; it leaves a gap in the office identifier sequence, which is not required to be contiguous, and no other durable change.
  • The system SHALL NOT delete, repair, resume, or reclaim an unsettled task, and SHALL NOT expire one. There is no duration after which behavior changes.
  • REST callers SHALL have a side-effect-free way to ask what holds an identity without risking a create: the lookup route. MCP callers do not get one in this iteration. What MCP gets is an idempotent create-if-absent operation — safe to repeat, but it creates a task when no holder exists, so it is not a probe. This asymmetry is deliberate and is stated rather than papered over; see The probe, and what MCP has instead.
  • Callers SHALL be able to release an external ID from a task, freeing it for reuse without deleting the task. This is an operator action, not a recovery step — see The one unsafe thing a caller can do.
  • Omitting the external ID SHALL leave task creation behaving exactly as it does today.
  • The external ID SHALL be accepted on the REST create endpoint and the create_task_kandev MCP tool. The WebSocket task.create action and the plugin host task-create API do not accept it in this iteration.
  • The external ID SHALL appear on exactly the task representations listed in Task representations. That table is the complete requirement; there is no broader "everywhere tasks are read" obligation.
  • The external ID identifies the external entity, not the request body. A second create for a held external ID SHALL return the existing task unchanged even when the rest of the payload differs; it SHALL NOT patch the existing task.
  • An external ID SHALL NOT be inherited by subtasks, copied on any task-cloning path, or auto-generated by the system.

The one unsafe thing a caller can do

Do not automatically release an identity because a create reported creation_complete: false, and then create again. This is called out explicitly because it is the intuitive "recovery" move and it is unsafe.

The failure:

  1. Create A commits its task row for identity E and continues its remaining work normally.
  2. Retry B observes E held by an unsettled task and returns creation_complete: false.
  3. B releases E and creates again, producing task T2.
  4. A finishes. Two tasks now exist for one external entity, and if both requested an agent, two agents are running.

The unique index prevents two simultaneous holders; it cannot prevent duplicates across a release. Releasing an identity that another create is actively using re-opens exactly the duplicate this feature exists to prevent.

Safe responses to creation_complete: false:

  • Proceed with the returned task ID. It is a real task.
  • Poll the lookup; if it settles, the original create finished on its own.
  • Escalate to a human, who can inspect the task and decide.

Release is for a human or operator who has determined the task is abandoned — not for an automated retry loop. Client libraries and agent tooling SHOULD NOT expose release as an automatic recovery action.

Data model

Two columns on the existing tasks table. There is no separate claim table.

tasks
  ...existing columns...
  external_id             TEXT       nullable  caller-supplied identity;
                                               NULL when the task has none
  external_id_settled_at  TIMESTAMP  nullable  when creation finished;
                                               NULL means not settled
  • Uniqueness: partial unique index uniq_tasks_external_id on (workspace_id, external_id) WHERE external_id IS NOT NULL. Supported by both SQLite and PostgreSQL. Naming it matters — violation handling must be attributable to this constraint specifically, never to "some unique index".
  • Settledness: external_id_settled_at IS NULL means the create that claimed this identity had not finished its required synchronous work. It does not mean that create is dead.
  • Empty or whitespace-only input normalizes to NULL (see Validation), so tasks without an external ID never collide with each other.

Why the identity lives on the task row

Putting the claim on the task itself, rather than in a side table, removes whole classes of failure by construction rather than by specification:

Hazard Why it cannot occur here
Claim and task disagreeing They are the same row. Nothing to keep in sync.
Orphan claim after a task is deleted The columns are deleted with the row, through any deletion path — including office handoff cascades that call the repository directly and bypass the service.
Non-atomic "mark deleted" transition There is no transition. Deleting the row releases the identity.
E2E reset leaving stale claims Reset already deletes tasks; the columns go with them. No reset change is needed.
A task with no claim, or a claim with no task One row, one insert. Impossible.

Validation and normalization

Applied to the raw caller-supplied value, in this exact order. The order is normative because it decides which error a malformed value produces.

  1. Reject ASCII control characters (U+0000–U+001F, U+007F) anywhere in the raw value — before any trimming. Newline and tab are control characters and are rejected here. Doing this before the trim is what makes a trailing-newline value an error rather than something the trim silently erases.
  2. Trim leading and trailing Unicode whitespace (any code point with the Unicode White_Space property).
  3. Empty after trim → treat as absent (NULL). Not an error.
  4. Length of the trimmed value in UTF-8 bytes MUST be ≤ 255.

Everything surviving those rules is accepted verbatim: jira:PROJ-1234, gh-issue/kdlbs/kandev#2325, a bare UUID, and non-ASCII letters are all valid.

Comparison semantics

Matching is byte-exact and case-sensitive after normalization. ext-1 and EXT-1 are different identities.

To make that true on both dialects, tasks.external_id and its index MUST use a binary/deterministic collation (SQLite COLLATE BINARY, the default; PostgreSQL COLLATE "C" or another explicitly deterministic collation). An unqualified TEXT column on PostgreSQL follows the database collation, which may be case-insensitive or nondeterministic and would silently violate this contract. No Unicode normalization is performed.

Lifecycle

  • Set once at create. No API changes an external ID on an existing task; PATCH /tasks/:id and the update MCP/WS surfaces do not accept the field.
  • Settled once at create, then never cleared or re-stamped except by release.
  • Archiving changes nothing. An archived task still holds its identity.
  • Deleting frees the identity. The row is gone.
  • Releasing frees the identity without deleting the task, setting both columns to NULL.

Idempotency is scoped to the task's lifetime

Once the task holding an identity is deleted or released, that identity is free, and a later create makes a new task. This is a real limitation and it is stated without excuse:

A consumer that redelivers indefinitely must suppress redelivery for entities it has itself deleted. Kandev retains no record of a consumed identity, so it cannot distinguish "never seen" from "seen and since deleted".

An earlier draft argued that a caller could detect this because the returned task ID differs from the one it recorded. That rationale is withdrawn: the motivating scenario is a caller that crashed before recording any task ID, and such a caller has nothing to compare against.

The alternative — a durable tombstone of consumed identities — was rejected because it requires the identity to outlive the task row, which reintroduces the side table and every synchronization hazard the table above removes. If a real consumer cannot suppress its own post-deletion redeliveries, that is the trigger to revisit this, and it should be treated as a design change rather than a tweak.

This limitation does not affect the motivating crash-recovery case, where no deletion occurs.

State machine

Two observable states for a task holding an external ID:

State Representation Meaning
unsettled external_id set, external_id_settled_at IS NULL The create that claimed this identity had not finished its required synchronous work at observation time. It may still be running.
settled both set That work finished.

Transitions:

From To Trigger
(none) unsettled A create wins the unique index and commits the task row
unsettled settled That same create completes its required synchronous work
any (none) The task is deleted, or the identity is released

There is no reclaim, no lease, no timeout, and no ownership token, because nothing in this design ever mutates another caller's task.

Create sequence (normative)

1. authorize workspace                       ← already first in CreateTask
2. validate + normalize external_id
3. LOOKUP by (workspace_id, external_id)     ← before ANY write or allocation
     └─ found → return Found outcome, stop. Nothing else runs.
4. required create-time validation
5. identifier allocation, WIP admission, task-row insert (incl. external_id)
     └─ unique violation on uniq_tasks_external_id → roll back, re-read, Found
6. required synchronous post-create work
7. SETTLE (conditional UPDATE, see below)
8. asynchronous dispatch (agent launch, PR association)

Step 3 is load-bearing and its position is normative. It must precede identifier allocation, WIP admission, and every other write. Two concrete consequences of getting this wrong, both of which violate the no-side-effects requirement:

  • assignIdentifier runs before persistence and calls IncrementTaskSequence, an unconditional UPDATE workspaces SET task_sequence = task_sequence + 1. A found outcome resolved after that point permanently burns a sequence number.
  • WIP admission can reject a create before the insert is ever attempted, so a retry for an already-held identity would fail with a capacity error instead of returning the existing task.

Step 5's unique-violation path remains as the TOCTOU backstop for the narrow race between the step-3 lookup and the insert. It is not the primary mechanism.

The backstop is not reachable from every failure, and that gap must be closed explicitly. WIP admission runs before the insert and can return a capacity error without ever attempting it. So a request that missed at step 3, then lost the race, can surface a capacity failure instead of the Found outcome the contract promises.

Therefore: after a step-3 miss, any pre-insert failure — capacity, admission, or otherwise — MUST trigger a re-read by (workspace_id, external_id) before that failure is returned. If a task now holds the identity, the request returns the Found outcome instead of the failure. Only if the re-read still finds nothing does the original error surface.

This is narrow but load-bearing: without it, "a retry always returns the existing task" is false precisely when the destination step is at its WIP limit, which is exactly when a task is most likely to already exist there.

Settlement (normative)

Settlement is a single conditional UPDATE. The predicate MUST include external_id:

UPDATE tasks
   SET external_id_settled_at = ?
 WHERE id = ?
   AND external_id = ?
   AND external_id_settled_at IS NULL

external_id in the predicate is required, not defensive. Release sets both external_id and external_id_settled_at to NULL; a predicate guarding only on id and external_id_settled_at IS NULL would still match a released row and stamp a settlement onto a task that no longer holds any identity.

The affected-row count MUST be checked.

  • One row — settlement succeeded. Proceed to asynchronous dispatch.

  • Zero rows — the task was deleted or its identity released while this create was running. The implementation MUST re-read current state and MUST NOT dispatch asynchronous work. It then splits by what it finds:

    • The task exists but no longer holds the identity (it was released) → return outcome CreatedIdentityLost: 200, deduplicated: false, creation_complete: true, and external_id absent from the body. The work finished; only the stamp had nowhere to land.
    • The task no longer exists (it was deleted) → return the surface's existing not-found error. There is no task to describe.

    It MUST NOT report outcome Created, and MUST NOT include external_id in the body — either would claim an identity the task no longer holds. Note that creation_complete: true is correct here and is not what distinguishes this outcome: the create's synchronous work did finish. The absent external_id is the distinguishing signal.

Zero rows is a legitimate outcome of a benign race, not an error to log and ignore.

Settlement call site (normative, per surface)

The service cannot settle, because required synchronous work continues in the handlers after CreateTask returns. Settlement is therefore the handler's responsibility, and the covered steps differ per surface:

Surface Required synchronous steps that must succeed before settling Settle after
REST attachment claim, workspace-policy attach, fresh-branch commit fresh-branch commit, and before any session dispatch
MCP remote-contribution association, workspace-policy attach policy attach, and before auto-start dispatch

REST's session helper must be split. It currently performs synchronous preparation and asynchronous dispatch in one call, swallowing preparation errors before dispatching the start goroutine. Settling after that helper would place settlement after async dispatch has already begun, violating step 7 → 8 ordering. The helper must expose preparation and dispatch separately so settlement can sit between them.

Best-effort steps do not block settlement. Task-create last-used recording and PR association are best-effort today; their failure MUST NOT prevent settlement and MUST NOT trigger compensation. Only the steps listed in the table above gate it.

What "settled" does and does not mean

external_id_settled_at means the required synchronous setup finished. It does not mean an agent is running, and it does not mean the create's process is alive or dead.

Callers needing to know whether an agent is running read session state, which already exists and is already authoritative.

API surface

Service contract

Service.CreateTask currently returns (*models.Task, error) and carries no outcome signal. It SHALL return:

CreateTaskResult {
  Task     *models.Task
  Outcome  enum { Created, FoundSettled, FoundUnsettled, CreatedIdentityLost }
}

CreatedIdentityLost is the fourth outcome, and it exists solely to represent the settlement zero-row race: this request created the task and finished its required synchronous work, but by the time settlement ran the identity had been released by another actor, so the task survives holding no external ID. Without it the contract has a reachable state with no representation — the handler would have to claim an identity the task no longer holds, or invent an undocumented response. See Settlement.

It is deliberately distinct from FoundUnsettled: that one means someone else's create is unfinished, while this one means this create finished but lost its identity. Callers act differently — the first says "wait or escalate", the second says "your task exists, but the identity you asked for is now free and someone may claim it".

It carries creation_complete: true. The work finished; only the settlement stamp had nowhere to land. Reporting false would break the field's single definition and falsely suggest in-progress work. The absent external_id is what identifies this outcome.

If the task was deleted rather than released during that window, there is no task to return and the request surfaces the appropriate not-found error; that is an error path, not this outcome.

Callers MUST skip their post-create work on both Found* outcomes:

Caller In scope? Post-create work to skip
REST httpCreateTask Yes attachment claim, workspace-policy attach, fresh-branch commit, session prepare/start, task-create last-used recording, PR association
MCP handleCreateTask Yes remote-contribution association, workspace-policy attach, auto-start launch
WS wsCreateTask Deferred agent launch, last-used recording
Plugin host Tasks().Create Deferred StartAgent best-effort start

The deferred rows cannot misbehave in this iteration because their surfaces do not accept an external ID, so the outcome is always Created for them. They are listed so enabling one later starts from a complete inventory.

⚠️ The MCP skip is a data-loss guard

Running MCP's post-create steps on a found outcome can delete the existing task. The handler resolves remote contributions from the request (handlers.go:658), then indexes them against the returned task's repositories (:715-719). A found outcome returns a task whose repository list need not match the retry payload, and the index guard at :719 calls DeleteTask at :720. Two further rollback paths do the same at :729 and :741. The sequence:

retry a create with an external ID → found outcome returns the existing task → post-create steps wrongly run → repository index mismatch → rollback deletes the task the caller was trying to recover

That turns crash recovery into data loss. Skipping these steps on a found outcome is a correctness requirement, and its scenario is required coverage.

REST — create

POST /api/v1/tasks gains one optional request field, external_id.

Response additions, both always present on every create response:

{
  "id": "…",
  "external_id": "jira:PROJ-1234",  // omitted when the task holds none
  "deduplicated": false,            // true for both Found outcomes
  "creation_complete": true,        // false ONLY for Found, unsettled
  // …all other existing task DTO fields…
}

deduplicated and creation_complete are required booleans, not presence-only markers. A presence-only field makes a serialization bug indistinguishable from a genuine fresh create.

The field is named creation_complete, not complete, because it describes one narrow thing — that the create's required synchronous setup finished — and a broader name invites callers to read it as "the task is done" or "the agent is running". Every schema and tool description MUST carry that narrow meaning.

The four success outcomes map to exhaustive, mutually exclusive tuples:

Outcome Status deduplicated creation_complete external_id in body
Created 200 false true present
Found, settled 200 true true present
Found, unsettled 200 true false present
Created, identity lost 200 false true absent
external_id fails validation 400 — — —
Caller not authorized for workspace_id 404 {"error": "task not created"} — — —

Reading the tuples:

  • creation_complete: false occurs in exactly one outcome, Found, unsettled, and only ever alongside deduplicated: true. That is the one tuple meaning "another create may still be in progress."
  • Created, identity lost carries creation_complete: true, because this create's required synchronous work genuinely did finish — only the settlement stamp had nowhere to land, since the identity was released. Reporting false would contradict the single definition of the field and would falsely imply in-progress work.
  • It is distinguished from Created by the absent external_id, which is the literal truth: the task no longer holds one. A caller seeing this tuple has a valid task ID and should treat the external identity as unclaimed.

All four success outcomes are 200 with the task body. There is no 409 and no 410: an unsettled task is a fact to report, not a conflict to raise, and a freed identity is indistinguishable from one never used. Keeping fresh creates at 200 also avoids a breaking change for existing clients.

The probe, and what MCP has instead

REST callers get a true probe: the lookup route below. It reads and returns; it never creates.

MCP callers do not. A create carrying an external ID is idempotent — safe to repeat, no duplicates, no side effects when a holder exists — but it is not side-effect-free, because when no holder exists it creates a task. Calling that a probe would be wrong, and an MCP agent told it was one could use it to "check" an identity and be surprised to find it had made something.

The MCP tool description MUST therefore say plainly: this creates the task if nothing holds the identity yet.

No MCP lookup tool is added in this iteration because no in-scope MCP flow needs to ask without being willing to create — an agent reaching for an identity is reaching for the task. If a flow appears that genuinely needs to ask first, add a read-only tool then; it is a small, additive change.

REST — lookup

GET /api/v1/workspaces/:id/tasks/by-external-id?external_id=<value>
Situation Status Body
A task holds it (including archived, including unsettled) 200 task DTO with creation_complete
No task holds it 404 {"error": "task not found"}
external_id missing or fails validation 400 {"error": "<reason>"}
Caller not authorized for the workspace 404 {"error": "task not found"}

Read-only; no side effects.

REST — release

DELETE /api/v1/workspaces/:id/tasks/by-external-id?external_id=<value>

Sets external_id and external_id_settled_at to NULL on the task that holds it. Does not delete or otherwise modify the task. Returns 204 on success, 404 when no task holds it, 400 on validation failure, 404 when unauthorized.

Release is an operator action for an identity a human has determined is abandoned. See The one unsafe thing a caller can do for why it must not be automated in response to creation_complete: false.

Request ordering and error precedence

Two orderings are normative:

  1. Authorization precedes any external-ID work. An unauthorized caller receives the existing unauthorized response and SHALL NOT learn whether an external ID exists there, whether it is held, or whether its task is settled. Service.CreateTask already authorizes as its first statement.
  2. Validation of the external ID precedes the lookup, so a malformed value is a 400 rather than an accidental lookup.

The step-3 lookup then precedes all remaining create-time work, per Create sequence.

Honest limitation — a retry can still fail before dedupe. Both in-scope handlers perform validation before reaching the service: REST validates attachments, launch-profile requirements, and repositories; MCP resolves repositories, workflow, workspace, contributions, and launch metadata. This spec does not require restructuring them. Two distinct causes follow:

  • Payload drift — the retry sends something different from the original. Mitigated by replaying the original payload.
  • Server-state drift — the retry is byte-identical, but the world changed: a repository was deleted, a workflow or step was removed, an agent profile was disabled, a parent task was archived, a credential expired. Replaying the original payload does not help here.

Server-state drift means the "a caller that crashed and came back a week later still finds its task" property holds for the lookup route, which touches none of that validation, but not necessarily for the create path. A caller that wants recovery robust against long absences should use the lookup route first and fall back to create only when the lookup returns 404.

This is a real weakening relative to an idealized idempotent create, and it is stated rather than implied. Engineering around it would require a create coordinator that resolves the identity before any handler validation, which was judged not worth its cost.

MCP

create_task_kandev gains one optional string parameter:

  • external_id — "A stable identifier from your own system (issue key, webhook delivery ID, a UUID you generated). Creating a task twice with the same external_id in the same workspace returns the first task instead of making a duplicate — use it when a retry or restart could re-run this call. Replay the same arguments you sent the first time."

The tool result is the task as JSON carrying external_id, deduplicated, and creation_complete. The tool description MUST state all three of these:

  1. This creates the task when nothing holds the identity yet. It is create-if-absent, not a lookup. An agent must not call it merely to check.
  2. deduplicated: true means the task already existed and was not created, so the agent must not report having created something new.
  3. deduplicated: true together with creation_complete: false means another create claimed this identity and had not finished when observed, and may still be in progress — so the agent must not assume the task is ready, and must not release the identity and create again.

Point 3 is scoped to that tuple deliberately. creation_complete appears as false in no other outcome, so an agent that keys off the pair cannot misinterpret it.

The workspace is resolved before the identity is used: explicit workspace_id, else the parent's when parent_id is set, else auto-resolution when exactly one workspace exists. The identity resolves against that effective workspace.

The pre-existing MCP-layer title check runs before any backend call, so an MCP replay carrying an external ID but no title gets the MCP validation error rather than a found outcome. This is existing behavior and is consistent with the replay guidance above.

Write surfaces NOT changed

The WebSocket task.create action and the plugin host Tasks().Create do not accept external_id. A caller supplying it there has the field ignored, exactly as any other unknown field is today; no error, no claim.

No dedupe event suppression is needed on the WebSocket: the found outcomes return early inside the service, so WS subscribers never see an event for a create that did not happen.

Task representations

external_id is added to exactly these. This table is the requirement.

Representation Location Carries it
dto.TaskDTO internal/task/dto/dto.go Yes — omitempty, like the sibling identifier field. Covers REST reads/lists and the MCP task tools, which project this DTO.
WS task lifecycle events publishTaskEventNow's hand-built map, service_events.go Yes — added explicitly. Not built from TaskDTO, so it needs its own change.
Plugin Task + SDK type + mapper plugin.proto, pkg/pluginsdk/data_types.go, internal/plugins/host_data.go No. Deferred with the plugin surface. Adding it later is additive on the proto (ADR 0043) and not breaking.
v1.Task pkg/api/v1/task.go No. Separate legacy projection.
Task-context references pkg/api/v1/task_context.go No. Lightweight ref.

creation_complete is a create-response and lookup-response field only. It is not added to the shared task DTO, because it is meaningful only in the context of an idempotent create; putting it on every task read would leak a create-time condition into unrelated surfaces.

Permissions

  • The external ID is workspace-scoped data and inherits the workspace's existing authorization rules with no additions.
  • Authorization precedes all external-ID work. An unauthorized caller receives 404 {"error": "task not created"} for a create and 404 {"error": "task not found"} for a lookup or release, and cannot learn whether an identity exists in that workspace or whether its task is settled.
  • Because uniqueness is workspace-scoped and workspaces are per-owner when auth is enabled, two users cannot collide on each other's external IDs or probe each other's namespaces.
  • An in-session agent calling create_task_kandev is already scoped to its task's workspace owner by the MCP identity scoper.
  • Release is a workspace-scoped mutation requiring the same authorization as any other write to that workspace.

Failure modes

Condition Behavior
Two concurrent creates race past the step-3 lookup The partial unique index admits exactly one. The loser's insert transaction rolls back — including its task row and runner participant — then it re-reads by (workspace_id, external_id) and returns that task as a Found outcome. Neither sees a 5xx, and no orphan row survives. A loser that already allocated an office identifier has burned a sequence number; see the row below.
A concurrent loser burned a task-identifier sequence number Accepted. The step-3 lookup makes this rare (it requires two creates to interleave within the window between lookup and insert), and the consequence is a gap in the office identifier sequence, not a correctness failure. Identifier sequences are not required to be contiguous.
Unique-violation classification across dialects The violation MUST be attributed to uniq_tasks_external_id specifically — by constraint name on PostgreSQL via typed pgconn.PgError inspection, and by the constraint-bearing message on SQLite. A bare 23505 check is insufficient: it matches any unique violation, so a primary-key failure would be misread as a Found outcome.
Crash between the task insert and settlement The task exists unsettled. A retry returns Found, unsettled. Nothing is deleted or repaired.
Crash after settlement, before the response reaches the caller A retry returns Found, settled.
The task is deleted while its create is still running Settlement affects zero rows. Async dispatch is suppressed, state is re-read, and the response reflects reality rather than claiming a create that no longer exists.
The identity is released while its create is still running Settlement affects zero rows (the external_id predicate no longer matches). Same handling as above; the task survives without an identity.
Found outcome where start_agent: true was requested No agent launched, no session created. This is the central safety property.
Found outcome on an archived task The archived task is returned with archived_at set. Not auto-unarchived.
Found outcome where the request carried different repositories, attachments, parent, or policy All ignored; the existing task is returned unmodified.
A retry fails handler validation because server state changed The request fails with that handler's existing error. Documented, not worked around. Use the lookup route for drift-robust recovery.
Workspace deleted Its tasks are deleted; their identities go with them.
A caller supplies an external ID on a subtask create Accepted and applied to the subtask, scoped to the effective workspace. Never inherited from the parent.

Persistence guarantees

  • Both columns live on tasks and survive restart with the row. Nothing is cached, and there is no TTL.
  • There is no idempotency window. A held identity resolves against the current table however old the task is.
  • An unsettled task persists indefinitely. Kandev never sweeps, expires, or repairs one. It is visible and mutable like any other task.
  • A released or deleted identity is gone irreversibly; no history is retained.

Scenarios

Golden path

  • GIVEN workspace W has no task holding ext-1, WHEN a client POSTs /api/v1/tasks with workspace_id: W and external_id: ext-1, THEN a task is created, the response carries external_id: "ext-1", deduplicated: false, creation_complete: true, a task.created event is published, and the row has a non-NULL external_id_settled_at.
  • GIVEN a settled task T holding (W, ext-1), WHEN a client POSTs the same create again, THEN the response is 200 with id equal to T.id, deduplicated: true, creation_complete: true, W still has exactly one task holding ext-1, and no task.created event is published.
  • GIVEN a settled task T holding (W, ext-1), WHEN a client GETs the lookup route, THEN the response is 200 with T's DTO and no task is created.
  • GIVEN no task holds (W, ext-nope), WHEN a client GETs the lookup, THEN the response is 404.

Lookup-before-write ordering

  • GIVEN an office workspace W whose task_sequence is N, and a settled task holding (W, ext-1), WHEN a client POSTs a create with external_id: ext-1 and office-task parameters, THEN the existing task is returned and W.task_sequence is still N — the step-3 lookup resolved it before any allocation.
  • GIVEN an office workspace W whose task_sequence is N and no task holding (W, ext-4), WHEN two office creates with external_id: ext-4 race such that both miss the step-3 lookup, THEN exactly one task is created, the loser returns a Found outcome, and W.task_sequence may be N+2 — the loser's allocated identifier is not reclaimed. This is the single permitted side effect of a Found outcome, and the sequence is not required to be contiguous.
  • GIVEN a workflow step at its WIP limit, and a settled task holding (W, ext-1) on that step, WHEN a client POSTs a create with external_id: ext-1 targeting that step, THEN the existing task is returned with deduplicated: true — not a WIP-capacity error. The step-3 lookup resolves it before admission is consulted.
  • GIVEN a workflow step with exactly one remaining WIP slot and no task yet holding (W, ext-2), WHEN two creates with external_id: ext-2 targeting that step race such that both miss the step-3 lookup, the winner consumes the last slot and inserts, and the loser is then rejected by WIP admission before reaching the insert, THEN the loser re-reads by (workspace_id, external_id), finds the winner's task, and returns it as a Found outcome — not the capacity error. This is the admission-preemption guard; without it the contract's "a retry always returns the existing task" promise fails exactly when the step has just saturated.
    • The one-remaining-slot precondition is load-bearing: if the step were already full before either request, neither could insert, so there would be no winner for the loser to find and the scenario would be unsatisfiable.
  • GIVEN the same saturated step and no task holding (W, ext-3), WHEN a single create with external_id: ext-3 is rejected by WIP admission and the re-read still finds nothing, THEN the original capacity error surfaces unchanged — the guard must not swallow genuine failures.
  • GIVEN a settled task holding (W, ext-1), WHEN a client POSTs a create with external_id: ext-1 and a repositories entry naming a repository that no longer exists, THEN the outcome depends on where that validation lives: if the handler rejects it pre-service the request fails (documented limitation); if it reaches the service, the existing task is returned. The implementation MUST state which, and a test MUST pin the actual behavior.

Unsettled outcome and the unsafe-recovery guard

  • GIVEN a task T holding (W, ext-1) with external_id_settled_at IS NULL, WHEN a client POSTs a create with external_id: ext-1, THEN the response is 200 with T.id, deduplicated: true, and creation_complete: false; T is unmodified; no new task is created.
  • GIVEN the same unsettled task T, WHEN a client POSTs that create ten more times over any span of time, THEN every response is identical and T is never deleted, repaired, or duplicated — there is no timeout after which behavior changes.
  • GIVEN the same unsettled task T, WHEN a client GETs the lookup for ext-1, THEN the response is 200 with creation_complete: false.
  • GIVEN a create for ext-1 still running its required synchronous work, WHEN a second caller releases ext-1 and creates again, THEN two tasks exist for ext-1's entity. This is the documented unsafe path; the test exists to pin the consequence, and the MCP tool description and REST docs MUST warn against automating it.
  • GIVEN an unsettled task T holding (W, ext-1), WHEN an operator DELETEs the release route and then a client POSTs the create again, THEN the release returns 204, T still exists with NULL external_id, and the create produces a new task with deduplicated: false.

Settlement predicate and zero-row handling

  • GIVEN a create for ext-1 that has committed its task row and finished its required synchronous work, WHEN settlement runs, THEN exactly one row is updated and asynchronous dispatch proceeds.
  • GIVEN a create for ext-1 whose identity is released by another actor after the row commit but before settlement, WHEN settlement runs, THEN it affects zero rows — the external_id predicate no longer matches — no asynchronous work is dispatched, and the response is 200 with deduplicated: false, creation_complete: true, and no external_id field, i.e. outcome CreatedIdentityLost.
  • GIVEN the same released-before-settlement race, WHEN the caller inspects the response, THEN it distinguishes this from Created by the absent external_id, and from Found, unsettled by deduplicated: false; AND creation_complete is true, because this create's synchronous work did finish.
  • GIVEN every success outcome across the whole contract, WHEN their response tuples are enumerated, THEN creation_complete: false appears in exactly one — Found, unsettled — and always alongside deduplicated: true.
  • GIVEN a create for ext-1 whose task is deleted by another actor before settlement, WHEN settlement runs, THEN it affects zero rows, no asynchronous work is dispatched, and the response is the surface's existing not-found error rather than a fabricated success.
  • GIVEN any settlement that affects zero rows, WHEN the handler continues, THEN no agent is launched and no PR association is started.

No side effects on a found outcome

  • GIVEN a settled task T holding (W, ext-1) with exactly one agent session, WHEN a client POSTs a create with external_id: ext-1 and start_agent: true, THEN T still has exactly one session and no new agent execution is started.
  • GIVEN an unsettled task T holding (W, ext-1) with no session, WHEN a client POSTs a create with external_id: ext-1 and start_agent: true, THEN T still has no session and no agent is launched — an unsettled task is reported, never resumed.
  • GIVEN a settled task T holding (W, ext-1), WHEN a client POSTs a create with external_id: ext-1, start_agent: true, one attachment, and a repositories entry naming a different repository with fresh_branch: true (the flag is per-repository, not top-level), THEN T still has exactly its original repositories, no new worktree or branch exists, the attachment is not claimed, and no last-used task-create settings are recorded.
  • GIVEN a settled task T holding (W, ext-1) and title "Original", WHEN a client POSTs a create with external_id: ext-1 and title "Changed", THEN T's stored title is still "Original".

MCP

  • GIVEN a settled task T holding (W, ext-1), WHEN an agent calls create_task_kandev with external_id: ext-1 resolving to W and start_agent: true, THEN the tool result JSON carries T.id, deduplicated: true, creation_complete: true, and no new task or session is created.
  • GIVEN an unsettled task T holding (W, ext-1), WHEN an agent calls create_task_kandev with external_id: ext-1, THEN the result carries T.id with creation_complete: false and no task is created or started.
  • GIVEN a settled task T holding (W, ext-1) whose repository list differs from the retry payload, WHEN an agent calls create_task_kandev with external_id: ext-1 and a repository_url that would resolve to a remote contribution, THEN T is returned, no remote contribution is associated, and T still exists. This is the data-loss guard; required coverage.
  • GIVEN a settled task T holding (W, ext-1), WHEN an agent calls create_task_kandev with external_id: ext-1 and a workspace_mode needing a workspace-policy attach, THEN no policy is attached and T still exists.
  • GIVEN a settled task holding (W, ext-1) and W is the only workspace, WHEN an agent calls create_task_kandev with external_id: ext-1 and no workspace_id, THEN that task is returned with deduplicated: true.
  • GIVEN a settled task holding (W, ext-1), WHEN an agent calls create_task_kandev with parent_id naming a task in a different workspace W2 and external_id: ext-1, THEN a new subtask is created in W2 holding ext-1, because the effective workspace is W2.
  • GIVEN an agent whose session belongs to a task in workspace W, and a task holding (W2, ext-1) in a workspace the agent's task does not belong to, WHEN the agent calls create_task_kandev targeting W2 with external_id: ext-1, THEN the call is denied by MCP identity scoping and reveals nothing about W2.

Uniqueness and concurrency

  • GIVEN a task holding (W1, ext-1) and nothing in W2, WHEN a client POSTs a create for workspace_id: W2 with external_id: ext-1, THEN a new task is created in W2 with deduplicated: false.
  • GIVEN two tasks in W with no external IDs, WHEN a third is created without one, THEN the create succeeds — absent identities never collide.
  • GIVEN a task P in W holding ext-parent, WHEN a client creates a subtask of P with no external_id, THEN the subtask holds no identity.
  • GIVEN no task holds (W, ext-race), WHEN two creates with external_id: ext-race are issued concurrently and both pass the step-3 lookup, THEN exactly one task in W holds ext-race, both requests return 200, the outcomes are (deduplicated: false, deduplicated: true), and no orphan task row exists from the loser.
  • GIVEN the same race against PostgreSQL, THEN the same assertions hold — an environment-gated PostgreSQL test, since the SQLite path passing is not evidence for it.
  • GIVEN a create whose task-row insert collides on the tasks primary key rather than on uniq_tasks_external_id, THEN the response is an error and not a Found outcome.

Validation

  • GIVEN any workspace, WHEN a client POSTs a create with an external_id whose trimmed UTF-8 length is 256 bytes, THEN the response is 400, no task is created, and no agent is launched.
  • GIVEN any workspace, WHEN a client POSTs a create with external_id "ext-1\n", THEN the response is 400 — the control character is rejected before trimming.
  • GIVEN any workspace, WHEN a client POSTs a create with external_id "\t", THEN the response is 400, not "absent".
  • GIVEN any workspace, WHEN a client POSTs a create with external_id " ", THEN the task is created with a NULL external_id, and the response omits external_id.
  • GIVEN a task holding (W, ext-1), WHEN a client POSTs a create with external_id: " ext-1 ", THEN that task is returned with deduplicated: true — trimming precedes the lookup.
  • GIVEN a task holding (W, ext-1), WHEN a client POSTs a create with external_id: "EXT-1", THEN a new task is created — case-sensitive.
  • GIVEN a PostgreSQL install whose database collation is case-insensitive, WHEN the previous scenario runs, THEN it still produces two distinct tasks — the column's deterministic collation overrides the database default.

Lifecycle

  • GIVEN a settled task T holding (W, ext-1) that has been archived, WHEN a client POSTs a create with external_id: ext-1, THEN T is returned with deduplicated: true and a non-null archived_at, and T remains archived.
  • GIVEN a settled task T holding (W, ext-1), WHEN T is deleted and a client POSTs the create again, THEN a new task is created with deduplicated: false — idempotency is scoped to the task's lifetime.
  • GIVEN a settled task T holding (W, ext-1), WHEN T is deleted by an office handoff cascade that calls the repository directly, THEN the identity is free and a subsequent create succeeds — no code path can leave a stale identity behind.
  • GIVEN a settled task T holding (W, ext-1), WHEN a client DELETEs the release route, THEN the response is 204, T still exists with NULL external_id and NULL external_id_settled_at, and the lookup returns 404.
  • GIVEN a task T holding ext-1, WHEN a client PATCHes /api/v1/tasks/T with an external_id field in the body, THEN T's identity is unchanged.

Permissions

  • GIVEN auth is enabled, a settled task holding (W, ext-1) owned by user A, and user B who does not own W, WHEN B POSTs a create for workspace_id: W with external_id: ext-1, THEN B receives 404 {"error": "task not created"}, the response carries no deduplicated, no creation_complete, and no field of A's task, and no task is created.
  • GIVEN the same setup but an unsettled task, WHEN B POSTs the same create, THEN B receives 404 — settledness is not observable across an authorization boundary.
  • GIVEN the same setup, WHEN B GETs or DELETEs the by-external-id route for W, THEN the response is 404.
  • GIVEN user B unauthorized for W, WHEN B POSTs a create for W with a 300-byte external_id, THEN the response is 404, not 400 — authorization precedes validation.

Read surfaces and the deferred write surfaces

  • GIVEN a task holding ext-1, WHEN it is returned by a REST read, a REST list, an MCP task tool, or a WS task lifecycle event, THEN each of those four carries external_id: "ext-1".
  • GIVEN a task holding no identity, WHEN any of those four returns it, THEN the representation omits external_id rather than sending null or "".
  • GIVEN a task holding ext-1, WHEN it is returned by an ordinary task read, THEN the representation does not carry creation_complete.
  • GIVEN a task holding (W, ext-1), WHEN a client sends the task.create WS action with an external_id field, THEN a new task is created and the field is ignored.
  • GIVEN a task holding (W, ext-1), WHEN a plugin calls Tasks().Create with StartAgent: true, THEN a new task is created and started normally.

Migration

  • GIVEN an existing database whose tasks predate this feature, WHEN the backend starts, THEN both columns exist and are NULL for every pre-existing task, uniq_tasks_external_id exists, and creating tasks without an external ID continues to succeed.
  • GIVEN the same database, WHEN the backend starts a second time, THEN the migration replays without error.

Out of scope

Deferred write surfaces (designed, not built)

Neither has a named consumer today, and they are not equally cheap to enable. Each is additive.

Surface Cost to enable later
WebSocket task.create Low. One request-struct field, one line in the service-call literal, and skip the launch and last-used recording on a found outcome. One file. Deferred because it is the UI's own path: nothing retries on the UI's behalf.
Plugin host Meaningfully higher. plugin.pb.go and plugin_grpc.pb.go are generated and committed, so a proto edit requires regenerating them through the Makefile proto target (installs protoc-gen-go, needs network). It also needs a new SDK method — do not change the existing TaskReader.Create signature, which is source-breaking for shipped plugins — a mapper change, and a release in each separate plugin repo.

Other non-goals

  • Detecting whether an unsettled create is still alive. No lease, heartbeat, or owner token. This is the deliberate boundary of the feature.
  • Repairing, resuming, adopting, or garbage-collecting unsettled tasks.
  • Any timeout or expiry on an unsettled task.
  • A tombstone for deleted identities. See Idempotency is scoped to the task's lifetime.
  • An MCP lookup tool. MCP gets an idempotent create-if-absent, not a probe; no in-scope MCP flow needs to ask without being willing to create. Adding a read-only tool later is small and additive.
  • Changing an external ID in place. Write-once; re-key via release + create.
  • System-generated external IDs.
  • Idempotency for anything other than task creation. spawn_session_kandev is not covered.
  • Restructuring the create handlers so identity resolution precedes handler-level validation. The consequence — payload and server-state drift can fail a retry before dedupe — is documented, not engineered around.
  • Retiring the existing integration dedupe tables.
  • Replacing the office runs.idempotency_key mechanism.
  • The office runtime create_task HTTP action.
  • Request-payload fingerprinting.
  • Cross-workspace uniqueness or a global namespace.
  • A UI surface for entering, displaying, or releasing external IDs.
  • Bulk lookup or bulk release.
  • Backfilling external IDs onto existing tasks.

Open questions

None. Every decision is settled; downstream steps implement as written and route back here if a change is needed.

Appendix: verified current state

Claims checked against the code at 09fa82144 (a fast-forward of origin/main at cba3838c8).

In-scope entry points. REST POST /api/v1/tasks (task_handlers.go:140 → httpCreateTask at task_http_handlers.go:826, body struct at :730) and MCP create_task_kandev (tool schema internal/mcp/server/server.go:805, client handler internal/mcp/server/handlers.go:142, backend handler at internal/mcp/handlers/handlers.go:559). Both converge on taskservice.Service.CreateTask (internal/task/service/service_tasks.go:124), which authorizes first at :125.

Why the lookup must precede allocation and admission. assignIdentifier runs at service_tasks.go:160, before createTaskWithCapacity at :166, and calls IncrementTaskSequence — an unconditional UPDATE workspaces SET task_sequence = task_sequence + 1 (repository/sqlite/task.go:2328). Admission checks precede the insert (task.go:215) and can return before it (:257). Both were confirmed by adversarial review.

Why settlement needs the external_id predicate. Release sets both columns to NULL, so a predicate of id plus external_id_settled_at IS NULL still matches a released row.

Why settlement lives in the handlers. The service publishes task.created and returns before caller-specific work (service_tasks.go:171, :190). REST then claims attachments (task_http_handlers.go:912), attaches policy (:933), commits a fresh branch (:947), and prepares/starts a session (:954); MCP associates remote contributions (handlers.go:715), attaches policy (:737), and launches (:749).

Why REST's session helper must be split. Preparation errors are swallowed at task_http_handlers.go:1237 while the start goroutine is dispatched at :1289, inside the same helper. PR association is background work at :1201. MCP auto-start is a goroutine at handlers.go:1303.

The MCP data-loss path. resolveMCPRemoteContributions derives contributions from the request at handlers.go:658; the handler indexes them against the returned task's repositories at :715-719; the guard at :719 calls DeleteTask at :720. Further rollback-delete paths at :729 and :741. Independently confirmed.

Schema placement. The tasks DDL is internal/task/repository/sqlite/base_schema.go:295. Per apps/backend/AGENTS.md, a column added to an existing table must come from an idempotent ADD COLUMN in runMigrations(), with anything referencing it following there, not in schema init. Precedents at base_migrations.go:165 and :1086. The insert to extend is insertTaskTx (repository/sqlite/task.go:316), whose task insert and runner participant are genuinely transactional — a unique loser leaves no row and does not double-count WIP.

Response shape. createTaskResponse (task_http_handlers.go:761) embeds dto.TaskDTO (internal/task/dto/dto.go:143); the current success path returns http.StatusOK, not 201 (:960). TaskDTO already carries the analogous optional Identifier at dto.go:195. Unauthorized/not-found maps to 404 via handleNotFound (internal/task/handlers/errors.go:20).

Representations are not all TaskDTO. WS task lifecycle events are a hand-built map in publishTaskEventNow (service_events.go:367). Plugins have an independent proto message, SDK type, and mapper (plugin.proto:216, pkg/pluginsdk/data_types.go:92, internal/plugins/host_data.go:878); CreateTaskResponse holds only Task task = 1 (plugin.proto:456) and SDK TaskReader.Create returns (*Task, error) (pkg/pluginsdk/host.go:138).

Dialect handling. Partial unique indexes work on both dialects. A typed pgconn.PgError precedent exists at repository/sqlite/task.go:2238. Every existing unique-violation helper matches SQLite text only (internal/office/repository/sqlite/wakeup_requests.go:246, internal/github/store.go:1433, internal/prompts/service/service.go:64, internal/sentry/store.go:948), and per apps/backend/AGENTS.md this package also runs against PostgreSQL.

Deletion shadow paths. Office handoff cascades delete tasks through the repository directly (handoff_cascade.go:332). This design is immune: the identity is a column on the deleted row.

Design provenance. Three independent adversarial reviews (OpenAI Codex, read-only). Pass 1 (unique index): 10 P1s, including a crash between the row commit and post-create work leaving a half-created task returned as complete. Pass 2 (reservation table + lease + reclaim): NOT READY, 7 P1s — no fencing token, destructive release of live claims, non-atomic tombstoning across deletion shadow paths, an unimplementable completion protocol given async work, unsettled rows visible to ordinary reads, unsatisfiable handler ordering. Pass 3 (identity on the task row, honest reporting): NOT READY, 7 P1s — the decisive one being that complete: false cannot distinguish a live create from an abandoned one, so the release-and-recreate "recovery" it implied could produce duplicate tasks and agents.

This version responds by removing the recovery claim rather than the information. The flag remains, explicitly diagnostic; the unsafe automated action is named and forbidden; the settlement predicate, its zero-row handling, its call site, and the lookup-before-write ordering are now specified concretely. All factual findings from every pass were independently re-verified against source before acceptance.