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

1104 lines
60 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-07
updated: 2026-08-08
owner: 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`:
```sql
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:
```jsonc
{
"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.