# Leases & handoffs

> The minimum state two agents need to avoid doing the same destructive work twice, and to pass an unfinished task to each other.

Section: Collaboration · Source: https://titen.dev/docs/leases-handoffs
Derived from: https://github.com/RamaAditya49/titen/blob/main/docs/architecture/collaboration.md

---
## Not a scheduler

Titen provides only the state needed to prevent silent collisions. Agent selection,
scheduling, retries and the model loop stay with the caller.

<div class="loopstrip"><span>handoff</span><i></i><span>accept</span><i></i><span>lease</span><i></i><span>work</span><i></i><span>checkpoint</span><i></i><span>release</span></div>

An orchestrator may take a post-commit event, choose a capable agent, and start it with a
handoff id. The agent still has to accept the handoff, acquire the lease, and compile
context under its own credential. Receiving an event never becomes membership.

## Leases: expiring ownership of one work item

A lease names a resource and claims it for a bounded time. Other agents can check it
before they start something destructive.

<figure class="code"><figcaption>lease.sh<b>bash</b></figcaption>

```sh
curl -X POST http://127.0.0.1:8787/v1/leases \
  -H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
  -d '{"resource_type":"deploy","resource_id":"checkout-service",
       "purpose":"run rollback smoke","ttl_seconds":600}'
```

</figure>

```json
{ "data": { "lease_id": "lease_b504df202cd34393959fe49bda97a062",
    "expires_at": "2026-07-30T10:58:13.829Z" } }
```

`ttl_seconds` runs from 10 to 86 400. Acquire looks up the unreleased lease for that
organization, `resource_type` and `resource_id`, and rejects when another principal holds
one that has not yet expired. A partial index over unreleased rows keeps that lookup
cheap. The second principal gets `409` with the expiry:

```json
{ "error": { "code": "CONFLICT",
    "message": "Resource is leased by another principal until 2026-07-30T10:58:13.829Z." } }
```

The message names no holder and no purpose, so probing for leases leaks nothing beyond the
fact that this one is taken.

<div class="callout callout--alarm">
<strong>RE-ACQUIRE IS NOT RENEW</strong>

There is no renew route. Acquiring again as the current holder, or after the TTL has
passed, releases the old row and inserts a new one, so you get a **new `lease_id`** and
the previous one immediately returns `404` on release. Keep the id from the most recent
acquire, or you will release nothing and think you did.
</div>

`DELETE /v1/leases/:id` releases, returns `{ "lease_id": "…", "released_at": "…" }`, and
returns `404` on a lease that is already released or belongs to another organization.
Nothing sweeps expired leases in the background: the expiry check happens when the next
principal tries to acquire.

<div class="kv">
<strong>WHAT A LEASE DOES NOT DO</strong>

<p><code>no fencing</code> a lease does not block writes; it is advisory state your agents agree to check</p>
<p><code>no auto-release</code> a crashed holder blocks the resource until its TTL expires, so keep TTLs short</p>
<p><code>no release event</code> <code>lease.acquired</code> is emitted to the event stream; release is recorded on the row, not as an event</p>
</div>

## Handoffs: an explicit transfer

A handoff says who should continue, about which subject, and optionally with which
context pack and checkpoint attached.

<figure class="code"><figcaption>handoff.sh<b>bash</b></figcaption>

```sh
curl -X POST http://127.0.0.1:8787/v1/handoffs \
  -H "authorization: Bearer $TITEN_API_KEY" -H 'content-type: application/json' \
  -d '{"to_principal":"agent_reviewer","subject_id":"user_rama",
       "checkpoint_id":"ckpt_713be19162544650a85b76b5ef6d7543",
       "message":"Smoke passed, needs approval to promote."}'
# → {"data":{"handoff_id":"hoff_a690e57e15ac4ee2a24bf9c80ae5a991","status":"pending"}}
```

</figure>

`GET /v1/handoffs` returns pending handoffs **addressed to the caller**. The sender's own
list comes back empty: this is an inbox, and the pull path for agents that cannot receive
a webhook. Poll it, or subscribe to the `handoff.created` event if you run a dispatcher.

```json
{ "data": { "handoffs": [ { "handoff_id": "hoff_a690e57e15ac4ee2a24bf9c80ae5a991",
      "from_principal": "agent_8df7b8699a0945488ceb3d4b104006d6",
      "to_principal": "agent_reviewer", "subject_id": "user_rama",
      "context_id": null, "checkpoint_id": "ckpt_713be19162544650a85b76b5ef6d7543",
      "message": "Smoke passed, needs approval to promote.",
      "status": "pending", "created_at": "2026-07-30T10:48:13.859Z" } ] } }
```

`POST /v1/handoffs/:id/resolve` takes `status` of `accepted` or `rejected`, and only the
target principal may call it:

```json
{ "error": { "code": "VALIDATION_ERROR",
    "message": "Only the target principal can resolve a handoff." } }
```

A second resolve returns `404`, because the lookup matches only `status = 'pending'`, so a
retried request cannot flip an accepted handoff to rejected. Each transition emits
`handoff.accepted` or `handoff.rejected` to the event stream.

## Conflict handling

Titen keeps five kinds of collision apart:

<div class="table-wrap">

| Situation | Behaviour |
| --- | --- |
| Conflicting facts from different sources | the claim stays `disputed` until evidence or an authorized resolution exists |
| Different observer opinions | stay observer-scoped, never averaged |
| A decision replacing an earlier decision | explicit supersession |
| Concurrent checkpoint writes | today the upsert takes the last write; the `state_hash` tells you it changed. Compare-and-swap on an expected version is designed, not shipped |
| Duplicate work attempts | the lease returns `409` and reports the competing ownership |

</div>

No model is allowed to declare consensus on any of these.

## From MCP

Two of the seven MCP tools cover this: `titen_lease_acquire` takes `resource_type`,
`resource_id`, `purpose` and `ttl_seconds`; `titen_handoff` takes `to_principal`,
`subject_id` and an optional `message`. Release, resolve and the pending list are
REST-only. The SDK covers checkpoints but not leases or handoffs; use `fetch` or MCP for
those.

## Next

<div class="cards">

<a href="/docs/checkpoints"><strong>Checkpoints</strong><span>The state a handoff carries, and why it expires.</span></a>
<a href="/docs/webhooks"><strong>Webhooks &amp; events</strong><span>Push the handoff event instead of polling for it.</span></a>
<a href="/docs/identity"><strong>Identity &amp; visibility</strong><span>What a principal is, and why each agent needs its own credential.</span></a>

</div>