# Manager lifecycle contracts

## Inspecting a managed name

Manager `inspect` keeps its successful response as the live managed-agent row. A live hit does
not read durable lifecycle state, so a temporary records-store failure cannot break inspection of
an agent the manager currently holds.

On a live miss, a static manager point-reads its durable slot row. A name with no slot, or a slot
whose phase is `retired`, remains `not-found`. A nonterminal slot returns
`failed-precondition` with `error.details[].kind =
ai.cotal.manager.static-slot-observation`. The detail carries the slot's `slotPhase`,
`owner`, `actor`, `slotLifecycleUid`, `cleanupComplete` when recorded, and `slotRevision`. It
then carries the separate lifecycle head's `headState`, `headOp` when present,
`headLifecycleUid`, and `headRevision`. Head fields are absent when provisioning has not written
the lifecycle head yet.
The error message carries the same diagnostic summary so string-only operator paths do not hide
the structured detail.

A slot row records the manager instance that owns it. In a space with more than one manager, the
class queue can hand `inspect` to an instance that does not host the name. When the row names a
different instance and is not `retired`, the miss returns `failed-precondition` with the same
detail plus `ownerInstanceId`, which names the only manager that can act on it. The message names
both instances, so a caller that reads only the string can tell it from `not-found`. A sibling's
`retired` row remains `not-found`. A named `cotal_despawn` resolves its target through this read
and cannot address an instance, so it asks again until the owning instance answers, up to 16
times.

The slot is read before the head. These records do not form one atomic snapshot, so the detail
also carries `readOrder: ["slot", "head"]` and `consistency: "ordered-not-atomic"`. A head can
advance between the reads. The issuance gate is not projected because the retirement operation
needed for this diagnosis is already recorded on the head, and reading a third record would add
another non-atomic edge without changing the per-name result.

If either durable read fails or exceeds its bound, the miss returns `unavailable` with
`ai.cotal.manager.static-slot-read-failed` rather than claiming the name is absent. That detail
names the inspected `name`, the failed `record` (`slot`, `head`, or `slot-or-head` when the layer
cannot distinguish them), and `operation: "read"`. User-auth managers do not own `mgrslot` rows,
so their inspect misses remain live-map reads.

## Custodied seat retirement

For Linux custodied seats, retirement requires the runtime's process-exit evidence before
freeing the alias or deleting its credentials and delivery state. Socket loss alone is not
proof of exit. The runtime retains the record captured at launch or adoption so a clean
custodian exit can unlink its file without losing the recorded boot and process identities.
If the file is missing, reaping uses that retained record and the existing kernel identity
checks. An unknown reference without either record refuses cleanup. Reused process ids
are never signalled on the strength of the old record.

## Listing the durable slots

Manager `slots` (`manager.read`, untargeted) lists the durable static slot rows this manager
owns. Only static managers hold these rows: a user-mode or open manager answers
`failed-precondition`, and a manager whose durable store is not standing answers `unavailable`.
Each row carries the same `readOrder` and `consistency` fields `inspect` uses, because the list
is read the same way: torn across rows as well as within each row's slot/head pair.
With no arguments, retired rows remain excluded. From manager cluster revision 32,
`{names: ["worker"], includeRetired: true}` point-reads up to 100 distinct aliases and
includes their retired rows. An absent or sibling-owned slot is omitted, not reported
as retired. Each returned row retains its principal, lifecycle UID, slot/head revisions,
phase and any native cleanup-complete fact. Consumers must match every requested
incarnation; a missing live row or an accepted stop is not a retirement result.
`live` reflects the manager's live roster at render time, not the durable row. A slot row
is never deleted, so a row whose latest operation is a DEL or PURGE marker is corruption:
the list answers `unavailable` naming that row, as `inspect` does for its name.

## Static chat sessions

From manager cluster revision 34, a static manager admits chat sessions with
`chat-session-open` and ends them with `chat-session-retire`. Both are untargeted and in the
`manager.chat` class. Only operator instruments carry their rows; the `spawn` and `run`
capabilities do not. A user-mode or open manager refuses both with `unimplemented`.

`chat-session-open` takes `{subscribe, allowSubscribe, allowPublish, label, ttl, sealTo}`. The
three lists are a spawn's, under a spawn's rules: `subscribe` is the session's initial channels,
`allowSubscribe` its read ceiling (empty means `subscribe`, and `subscribe` must lie within it),
and `allowPublish` its post list (empty means none). Each may be omitted. A session can join any
channel under its ceiling, patterns included. A concrete event channel of another principal is
refused, as it is for a spawn. The manager draws a `chat_<26 base32>` alias and a fresh nkey
identity, runs the static slot activation, provisions the session's DM and DELIVER durables and
ACL row, and mints an agent-profile leaf with no role, capabilities, endpoint rows or issuance.
The leaf expires after `ttl` seconds, at most 900, and is never renewed. Because the leaf is not
an issuance, the manager writes its credential ledger row itself before the credential leaves the
process, and that row is what retirement marks revoked and verify-evicts.

`sealTo` is a one-time curve public key (`X…`) the caller mints and keeps the private half of. The
reply is `{alias, actor, lifecycleUid, expiresAt, sealer, sealed}`: `actor` is the nkey the
broker authenticates, `alias` names the slot, and `expiresAt` is in epoch milliseconds. The
credential is not in the reply in the clear. `sealed` is base64url, sealed to `sealTo` from the
one-time `sealer` key with the NATS xkey format, and opens to the credential bound to the space,
the admitting manager instance and every field of the reply. A generic invoke, an agent or a
browser sees only that ciphertext. The chat adapter opens it once, refuses it unless every bound
field matches, and keeps the credential in memory.

`chat-session-retire` takes `{alias, actor, lifecycleUid}` and runs the static terminal. It refuses
before any effect unless the alias is a chat alias whose slot this manager instance owns at that
uid and actor. It answers `retired`, `alreadyRetired`, or `failed` with a `retirement pending:`
reason while the slot stays held. It answers after the terminal settles, and the terminal waits up
to 15 seconds for a delivery daemon that does not answer its eviction, so a caller's deadline must
outlive that wait. Retirement marks the credential's ledger row revoked, verifies
that the session's principal has no broker connection left, tears down its broker footprint and
retires the slot. A static mesh has no broker-side credential revocation, so a copy of the leaf
can still connect with the permissions it carries until its expiry. An ordinary spawn refuses any
name that starts with `chat_`.

The manager that admitted a session retires it at its expiry when nothing retired it sooner, so a
lost reply or an abandoned client does not hold the slot. Its stop retires every session it still
holds, with or without its agents, and names each one it could not retire; a stop that preserves
the manager's state leaves them. Its own startup sweep never retires a session it is admitting or
holds. A session that none of these retired stays held until the next start of that manager
instance.

## Persona write observation

The Manager serves `define-persona` and `remove-persona` under `manager.persona`. Removal takes
`{name}` and returns `{name}`. It deletes only the machine-local definition, not agents already
running from it. A missing definition is `not-found`. The name may exceed the current 64-character
spawn limit, provided it is still a safe catalog filename stem, so an invalid legacy definition can
be removed.

An unanswered `define-persona` or `remove-persona` command does not prove the write did not land.
The caller must read the catalog again on the same manager before acting. The write may still land
after the wait ends, so a missing readback alone does not make another write safe. A failed describe,
broker no-responders answer or explicit pre-effect refusal remains a failure, not an unknown write.

## Spawn is a goal

Long-running commands are **actions** ([SPEC §13.6](/docs/spec/#136-composites)): the caller
submits with a client-generated `goalId` and a request fingerprint, the endpoint records a
durable accept or reject decision, progress rides per-goal events, and the work ends in one
terminal outcome (`succeeded`, `failed`, `cancelled`, `expired`, or `uncertain`). Spawn is
the reference case. Rather than block the caller for up to 30 seconds while an agent comes
up, the manager accepts the goal and returns the allocated identity at once:

```json
{
  "name": "reviewer_2",
  "owner": "u_...", "actor": "reviewer_2", "uid": "...",
  "goalId": "...", "fingerprint": "...",
  "readinessDeadlineMs": 30000,
  "executor": { "lifecycleUid": "...", "epoch": 3 }
}
```

The `uid` is the lifecycle the agent runs at. On a participant manager whose host enrolls its
agents, the host picks that uid, so the manager accepts the goal only after the host has answered.
A host refusal there refuses the spawn, and no goal is bound.

Resubmitting the same `goalId` returns the same acceptance. A `goalId` belongs to the caller that
submitted it, so another caller's spawn under the same id is its own goal with its own
acceptance. A sibling instance or a restarted one
answers it from the identity the accepting instance recorded before it replied, owner included.
When that record names no owner because an older manager wrote it, the resubmission is refused
`unavailable`. Once the goal has ended, the accepting manager keeps its acceptance for five more
minutes. After that, and at any other instance, the resubmission is answered from the goal's
terminal and never accepted again: a `succeeded` terminal names the agent, so it returns the same
acceptance, and any other outcome refuses it `failed-precondition` with that terminal attached.

The name is the one actually allocated: a persona-derived collision is auto-numbered
(`reviewer`, then `reviewer_2`), while a hard-pinned `--name` that collides with a live
agent is refused at accept, before anything is minted. Auto-numbering never hands out a numbered
name it has already issued in that manager process, even after the agent holding it is gone, so
a collision takes the next number. Only numbering consults that history: a hard-pinned `--name`,
or a persona whose own name is a numbered string, takes that name whenever it is free, and
numbering does not skip a string such a spawn held before. The triple plus `goalId` let the
caller follow progress (connector handoff, process launched, presence join) and reconcile
later against the exact instance that accepted. Presence within the manager's default
30-second readiness window, or a connector's declared bounded window, settles the goal
`succeeded`; an early process exit is `failed`; the window passing with neither is `uncertain`,
a bounded, durable outcome that a later `ps` or status read settles against the live roster.
`uncertain` is a real terminal outcome, not an absence and not a silent hang. It carries the
diagnosis of whoever owned the deadline: for a launch that
names the agent and says to inspect it rather than re-issue, since re-issuing after a launch
that in fact succeeded mints a duplicate. A follower keeps the acceptance as the data of any
terminal other than `succeeded`, so `cotal_spawn` returns an uncertain launch as a pending result
instead of an error: it names the allocated agent, its id, and its manager, and tells the calling
agent to watch the roster. A committer that supplies no diagnosis falls back to
"the success signal did not arrive within the readiness deadline". The agent's own eventual
state is then observable on its presence record.

The acceptance carries that exact `readinessDeadlineMs`. A synchronous follower treats its own
request deadline as a floor and waits through the accepted readiness budget plus delivery margin,
so a connector-specific slow boot cannot be reported as a caller timeout while the manager is
still legitimately waiting for its terminal.

The manager keeps a spawn or launch exception's error code and details when turning it
into a refusal. Failed `ControlReply` values keep their code and details at the serving
boundary. A host enrollment refusal that proves nothing was executed also keeps its
code in the public `Manager.startAgent` reply after staged-secret cleanup. A reply
without a code remains `failed-precondition`.

A spawn that is **refused** because a lifecycle barrier already holds the actor (a frozen
issuance gate, a retiring alias, a retired uid) is not a wait-timeout. The manager already
knows the blocked op (`registration` / `retirement` / `activation` / `takeover`), the `opId`
holding it, and the remedy when one exists (`retry`, `cotal reconcile-gate`). The detail
carries `headState` (`active` / `retiring` / `retired`) only when the refusing site read the
lifecycle head, and `gateState` (`frozen` / `retired`) only when it read the issuance gate. A
gate frozen by a takeover or a registration says nothing about the head, so that refusal
carries `gateState=frozen` and no `headState`. Those facts ride `error.details[]` as
`kind = ai.cotal.ep.lifecycle-blocked` and are also appended to the error string, so a
caller that only prints `error.message` still sees them. The CLI and the connector tools hand a
refusal on in one shape, so `cotal spawn --detach` keeps the same code, details, rendered facts
and acceptance data as `cotal_spawn`. A connector that collapses the
refusal to "startup failed (unknown)" or a SPEC 13.6 wait-timeout is hiding a knowable
state, not reporting a missing one.

## Attach sessions

`cotal attach` no longer returns a `ws://127.0.0.1` URL. It creates a one-use, holder-bound
session offer: the manager mints a token bound to the caller, the target lifecycle, its own
instance id and epoch, and an expiry, and replies with a session id and expiry only, no URL
and no secret in the reply. The CLI redeems the offer over the mesh (a second redeem is
refused). On a registered open mesh that redeem is a bare connection, the same path other
control commands already use; on a static-auth mesh it is still a session-caller credential
minted from the resolved root's seed. On a user-auth mesh the CLI holds no seed: it exchanges its
login and the grant for a `session-caller` view bearer, and the callout mints the same caller rails
with the grant's expiry. Terminal bytes then stream on core-NATS session subjects
scoped to the two parties. Backpressure is a bounded in-flight window with an explicit drop notice, never
silent loss; a late attach still repaints the full screen from a replayed terminal
snapshot. Close, expiry, target despawn, and a manager restart are distinct, surfaced end
states: a restarted manager's successor refuses the old epoch's sessions. `cotal attach`
reconnects when its link dies by asking the manager for a new session
([CLI](/api-reference/cli/#managed-seats)).

## Seat input

`attach` is a stream, so it is the wrong shape for a program that wants to send one line: it
holds a session open and expects a terminal at the caller's end. The `input` command is the
other half. One authorized call writes text into a running seat's terminal as if it had been
typed there, and answers with the seat and the number of bytes delivered.

It exists for **harness commands**. A line beginning with `/` (`/compact`, `/clear`, `/model`)
is neither chat nor an event: the agent's own harness handles it, and the keyboard is the only
way in. An external control surface that can already read a seat's turns and talk to it still
cannot drive it without this.

The op is targeted, rides the `manager.lifecycle` capability, and declares authz modes `owner`
and `any`, the row shape `attach` and `despawn` already carry, checked by the same authorization.
Enter is appended unless the caller suppresses it, and nothing is echoed back, since the resulting
turns already have somewhere to go.

**Who may call it is narrower than either of those**, and the reasoning is worth stating because
the natural assumption is wrong. `despawn` and `attach` are granted to anything holding `spawn`;
`input` is granted only to operator credentials. The tempting argument for treating them alike is
that an attach session's `write` already reaches the same terminal, so `input` adds nothing. It
does not reach it: an attach yields a signed session offer, and redeeming one needs a per-session
credential minted from the space signing seed, which no agent holds. So `input` would be new
authority, and the own-owner rule that bounds `despawn` covers every seat under an owner rather
than only the ones a caller launched. Killing a peer is denial; typing into a peer is control of
it. The write therefore sits with the credential that is already the administrative authority for
the domain.

Only a runtime that owns the child's input stream can serve it. The `pty` runtime does; the
external terminal runtimes attach to a process they do not own, and there the command refuses
and names the runtime rather than dropping the keystroke. A seat that is not running refuses for
its own reason, and the two are distinguishable, so a caller can tell "this will never work"
from "not right now". Both refusals carry `not-executed`, because nothing was typed. A write the
runtime rejects once delivery has started states no outcome, so a caller reads it as `unknown`.
The manager's other control refusals follow the same rule: one raised before the command's first
effect, such as a denied caller, a stale target or a missing persona file, carries `not-executed`.
See [cli.md](/api-reference/cli/#input).

## Grants

There is no broad control credential. A caller holds one capability row per command it is
allowed to send, and minting maps each named capability to the request subjects it needs and no
others. The manager serves over a scoped serve credential that can answer and
reply but cannot, for instance, write another endpoint's records or forge a goal terminal;
the goal-fact writer and the session writer are separate, narrowly scoped credentials the
broker fences by subject. Authorization is checked at the serving boundary, and for actions
it linearises at acceptance: a spawn refused there mints no reservation and leaves no
process. See [SPEC §13.9](/docs/spec/#139-authority-boundary) and
[identity & auth](/docs/identity-and-auth/).

A carried resume transcript never rides the rails. The operator-only `transcript-receive` command
answers whether to upload and hands back a one-time claim for `spawn`, and the bytes travel through
the target instance's own transfer bucket under two one-shot credentials: a writer the operator
mints for that one transcript, and a reader the target instance mints for its own bucket, or that
the host issues a remote manager through its `transferReader` authority operation.
