--- title: Workflow effect contracts description: Seat ownership, budgets, supervision, decisions, presence, turns, cancellation, and placement. kind: Reference audience: Workflow authors and runtime implementers --- ## Personal native participants [#participant] A run started by an approved personal native conversation may bind that same session as a durable turn target: ```js const session = await participant({ name: "native-session" }) await turn(session, { name: "review", deadline: "30m" }) ``` The program cannot name an actor or lifecycle. `participant()` reads the immutable run admission, pins the exact personal lifecycle in the journal, and refuses if that lifecycle is not live. Before starting the program, the conversation opts in with `cotal_turn_participate`. The manager issues a short lease for its self-only turn relay. The lease grants no stop, attach or terminal custody, and must be renewed after a reconnect or manager restart. ## Spawn ownership [#spawn-ownership-and-cleanup] Agent arguments take the handle returned by `spawn` or `participant`, not a name such as `"bob"`. Keep the returned value and pass it to `turn`, `ask`, `monitor`, `replied` and `down`. The same rule applies to members of `notify` and `conclave` and to `message`'s `from` option. A malformed handle is refused at evaluation with L3050, naming the argument to repair, before the effect is dispatched. The host still checks whether that handle belongs to this run and whether the operation supports its kind. `spawn` is the manager's spawn action submitted under the step's own identity: the goal binds under the step's request id, so a resumed run re-attaches to the same seat instead of allocating a second one, a spawn whose seat fails to come up is catchable as L4002 with the manager's recorded reason (one the endpoint refuses at accept is L4000, described below), and a spawn on a race branch that loses is despawned by the run's own cancellation sweep. A seat belongs to the run that spawned it: when the run completes, it despawns every seat it spawned, including a race winner's and one whose spawn failed while its process stayed up. A spawn marked `onFork: "adopt"` is the exception: a fork can share that seat and no run can see whether another still uses it, so the seat stays up until you stop it with `cotal stop` once every run sharing it is done. A seat a migration handed to a later spawn follows that spawn's policy, and it stays up if any spawn that held it was marked `onFork: "adopt"`, because a fork taken before the migration may still share it. In a space with several managers, a despawn counts a seat as already gone only when the manager that allocated it says so. A run that fails or is released keeps its seats until a resume completes it or you stop them with `cotal stop`. Start a seat with `cotal spawn` when it should outlive any run. ## Seat budgets [#seat-budgets] `permits` are the budgets this host meters: `turns`, how many turns the run may dispatch to the agent, and `wallClock`, a duration from the spawn after which no turn is admitted. The turn that would exceed one is the catchable L4001 (kind `permit-turns` or `permit-wall-clock`; a deadline the remaining wall clock cannot hold counts as exceeding it), an adopted run counts the turns its journal recorded, and a budget the host has no meter for, such as `tokens` or `spend`, is refused at the spawn rather than accepted and ignored. ## Supervision [#supervision] `supervise` requests a bounded restart policy: `restarts` counts process deaths in `window` (default `10m`). Before restarting, the manager requires the runtime to prove that all prior local work ended, including tools in separate sessions or process groups. Parent exit and an empty original process group are insufficient. Missing, failed or timed-out proof refuses the restart. The built-in PTY runtime does not provide work closure and refuses `supervise` before launch. Ordinary launches without a restart policy remain available. Check any surviving tools and their outcome before manually starting the work again. The manager does not cancel completed remote effects or guarantee at-most-once execution. A runtime that provides closure can restart within the policy budget under the same name, lifecycle UID, persona, worktree and permits. Pending turns remain addressed to that lifecycle; finished turns are not replayed. After a manager crash, recovery additionally requires a durable custody reference and a runtime operation that proves all work under that reference ended. Older PTY records without that reference are refused, even if the seat process exited. A refused recovery retires the seat. Outstanding turns retain their existing deadline and death record; refusal does not invent a successful or cancelled result. Unknown policy keys, user-mode seats and runtimes that cannot respawn a name in place also refuse at spawn. A spent restart budget, changed persona or expired credential prevents recovery. ## Event plane [#event-plane] `events: false` is the workflow form of `cotal spawn --no-events`: the seat starts without its AG-UI event plane. A connector that publishes none, such as Hermes, needs it, because an omitted `events` arms the plane and the manager refuses that connector at the spawn. A value that is not a boolean is refused at the spawn. `runtime` names the terminal runtime of this one seat (`pty`, or an installed extension such as `tmux`). Omitted, the seat uses the serving manager's own runtime. A name that manager cannot resolve refuses the spawn and starts nothing, and the other seats keep the runtime they already have. ## Conclave membership [#conclave-membership] `conclave` joins its members to a real channel as durable membership rows: the channel derives from the step's own request id when the program names none (a program-named channel is borrowed, never torn down, and a membership that predates the conclave survives its close), each member handle resolves to its principal through the seat's own presence row (an absent member is catchable as L4002), each member's row is written under that lifecycle's exact read ACL (read just before the write and again after it, so a member retiring meanwhile gets no row and keeps none), and a conclave cancelled on a losing branch is released by the same cancellation sweep. ## Ask attempts [#ask-attempts] `ask` parks one checkpoint-plane pause per attempt, answered through `cotal run answer` as a checkpoint is, and tells the agent through the same relay `turn` uses: one relay per attempt under the attempt's own token, carrying the schema, the attempt count, the deadline and the previous refusal, which the seat's connector renders as the record wanted and the native command that answers it. The relay names the ask's absolute deadline, so the manager stops serving an attempt at the instant its pause expires and refuses a relay already past it. The seat calls `cotal_describe` for endpoint `manager`, then `cotal_invoke` with `endpoint: "manager"`, `command: "run-answer"`, `self: true` and `args` containing the relayed `runId`, `stepKey` and requested `value`. This uses the native connection's own identity even when the harness strips the shell's mesh environment. It needs no `run` grant or `run-status` read. Answer before ending the host turn. `cotal_yield` submits no answer value and can withdraw the pending relay that authorizes a baseline answer. The manager records the authenticated caller as the answerer. A spawned seat's baseline credential carries only the self-targeted `run-answer` row, and the manager accepts it only for the open ask or escalation relayed to that exact incarnation. It cannot answer another seat's ask, an unrelayed checkpoint, another run, or start and resume commands. An ask addresses an agent the run spawned (anything else refuses before an attempt opens), a resumed attempt tells the seat nothing twice, and a seat gone at the relay is L4002. On the pause itself: the shorthand of the language reference §6.5 is enforced (an unreadable schema is L4022), a non-conforming answer costs one attempt and its refusal reason is recorded on the entry for the answerer to read, exhausted attempts (default one) are the catchable L4006, and so is the one absolute deadline for the whole ask passing with no conforming record (its kind is `ask-deadline`), including one that passes before an attempt's pause is armed. ## Checkpoint journal entries [#checkpoint-journal-entries] `checkpoint` binds what it asks on its own entry, so `cotal run show` prints the question under the step key an answer is addressed by while the pause is open: the address alone left whoever was asked reading the source to find out what "approve" meant. When supplied, its schema is recorded beside the question and printed as JSON. Checkpoint schemas remain uninterpreted; they guide the answerer without restricting the answer. An older entry that recorded no schema carries none. The entry also records the open attempt's deadline and the `onExpiry` it was armed with, which `run show --json` reports; what an expiry does is still decided from the program's source. An escalation's attempt records its own deadline, one `timeout` after it opens, and a resumed attempt keeps the deadline it recorded. After a checkpoint or `ask` accepts an answer, the journal prints that accepted answer's recorded value and attribution under the settled step. A checkpoint's comes from its frozen result, and the journal never substitutes another filed answer or invents fields that result does not hold. An `ask`'s result is the value alone, so its line is read from the answer record its last attempt's settle named, even when that value is a record with fields named like a checkpoint's. Amendments print on their own lines after it. ## Escalation [#escalation] An `escalate` addressed to an agent this run spawned is relayed to that seat through the same turn relay an `ask` uses, carrying the prompt and the token to answer under, held to the pause's own deadline; a `to` naming anyone else is a person, and their pause stays the one anybody can answer, with the addressee recorded and rendered beside the question. ## Presence liveness [#presence-and-down-waits] `monitor` registers interest in an agent, and the registration is the journal entry itself, carrying the handle it registered: monitoring an agent that is already dead succeeds, and the death is the wait's to observe. `wait(down(...))` observes a monitored agent, and refuses one the run never performed `monitor` on. It reads the death off presence liveness, the same witness a conclave join resolves members through: the value carries the handle, the reason (`lapsed` when nothing live holds the name any more, `superseded` when a live row holds it under a different incarnation) and the time of observation. Presence liveness skips any value without a string `card.id` equal to its key, a string `card.name`, a known `status` and a finite `ts`, so a participant that publishes a malformed row under its own key cannot fail another run's wait, turn or conclave join. A superseded incarnation is down at once. A lapsed one is down only after its presence row has stayed gone for 30 seconds, because a seat whose connector stalls past the row's 6-second TTL, under host load or across a reconnect, renews it under the same incarnation and is still working. The 30 seconds count only across presence reads that each end within 6 seconds of the previous one starting, so a slow read or a run of failed reads, which could hide a renewal, starts the count over. Even then the run asks the manager first: a seat whose process the manager still runs is alive however long its row has been gone, so the count starts over. Only the manager that spawned the seat can report it gone, because another manager in the space knows only its own seats, so the run asks again until that one answers. When it does not answer, the presence row is the only witness and the lapse stands. The asking stops at the wait's timeout or cancellation, and at a turn's deadline, yield or cancellation, which then decide. A wait that begins after the death resolves once that holds, and a timeout resolves null on one absolute deadline a resumed run re-attaches to. ## Turn relay [#turn-relay] `turn` wakes one seat for one host turn through the manager: the run submits the turn under the step's own identity, the manager holds it as a goal pinned to the seat's incarnation, and the seat pulls it under its own reach ahead of its next host turn, so nothing is pushed into a session mid-thought. The manager sends the seat a turn notice whenever its pending turns change, and repeats it every 5 seconds until the seat pulls a new turn. The seat pulls when a notice arrives, when it first connects, and after a reconnect while it holds a turn, so an idle seat sends no requests ([SPEC §14.3](../SPEC.md#143-the-run-record)). The payload the seat reads names the run and the step and carries the rendered run context, plus any pending notices addressed to it, which the turn consumes. The seat yields through `cotal_yield` (`done`, `blocked`, or `handoff` with an addressee), and ending its host turn yields `done` for every turn it was shown. A `cotal_status` call changes presence only, including when it sets `idle`. It does not yield a turn. If a yield times out with an unknown outcome, it may already be recorded. After reconnecting, retry the same turn id and intended status. An already-settled reply confirms a terminal turn, not which attempt settled it. Success records a still-active turn, so this retry is not a read-only check. A `handoff` names another seat the same run spawned: the next `turn` in the same scope to that seat records the link, a handoff to a name the run never spawned is the catchable L4005, and one to a seat bound to a different worktree is L4004. The deadline elapsing before any yield is the catchable L4003: the acceptance names the instant, the manager's goal-bound hold denies at it, and the run arms its own pause on that same instant, so either side outliving the other still converges on the same answer. A seat that pulled the turn earlier stops showing it to its session at that same instant. A seat that dies mid-turn is read off its own presence row by the run itself, with the same 30-second confirmation for a lapsed row, and is the catchable L4002, and a death the manager marked on the deadline terminal reads the same way. Two turns on one seat, from two branches or from two runs, reach it one at a time: the language dispatches the second when the first settles, and the manager shows a seat the oldest unsettled turn alone. A turn's goal id belongs to the caller that submitted it, so two callers may each submit a turn under the same id. A seat yields by goal id alone, so the manager refuses to hand one seat a second caller's turn under an id it already holds. On an auth mesh the relay needs no extra grant: every spawned seat's baseline credential carries its own pull, yield, and caller-bound answer rows, the run driver's operator instrument carries the turn request and the `inspect` read that confirms a lapse, and the manager arms the deadline hold over its own serve grant and expires it itself once due. An accept the manager cannot finish is unwound to a failed terminal on the goal it bound, and a retry of that submission is refused naming the terminal rather than accepted a second time. While a turn waits, a NATS client timeout reading its result, deadline or presence is retried with bounded backoff. The diagnostic names `readGoalResult`, `expired` or `presenceDeath`. Six unanswered attempts fail with L4026. Six attempts affected by measured host starvation fail with L4025. The counters are separate and neither resets during a read's retries. Other read errors are not retried. A turn that fails on a read claims its primary timer and withdraws its relay before returning, so a later yield cannot report that it continued an abandoned turn. A cleanup failure reports both errors without replacing L4025 or L4026 with L4000. Withdrawal must finish before the failure returns and can wait until the relay's deadline if the manager is unreachable. ## Reply waits [#reply-waits] `wait(replied(...))` observes those turns from another branch: a completed turn is a reply, and the wait resolves with the observation record (the handle, the yield's status and note, the yield's own stamp). It reads as a level, the way `wait(down)` does: a reply that already exists resolves the wait at once, and two replies resolve to the latest by the yield's stamp. A denied or cancelled turn is never a reply, so an unanswered wait rides its own mediated timeout to `null`, and a handle the run never spawned or turned refuses loudly, since only this run's turns are observable. A turn the run itself ended without an accepted yield (its deadline, a cancellation, a refused handoff) is never a reply, whatever the seat yields to the relay later. ## Cancellation [#cancellation] A cancelled branch also withdraws what it relayed to a seat before its cancellation completes: its turn, its ask attempt or its escalation ends `cancelled` through the manager's reserved `cancel` (SPEC §13.6), so the seat is not shown it after the branch's scope settles and the next turn to that seat does not wait behind it. Only the manager that accepted the relay holds it, so in a space with more than one manager the run sends the cancel again while another manager refuses it, until the accepting one answers. A cancel refused as `unavailable`, such as by a manager still reconciling at boot, or one whose reply is lost (`deadline-exceeded`) is sent again until it lands or the relay's deadline passes, so the branch's cancellation does not complete while the seat can still be shown the relay. Any other refusal fails the branch's step at once under its code, since sending it again changes nothing: a manager that serves no `cancel` is refused `not-found` before anything is sent, and an accepting manager with no such relay pending refuses `failed-precondition`. The run reads the relay's deadline from its goal and retries a failed read, since the relay may still be served until the deadline is known. A goal record that can never yield a deadline, such as one that is not JSON, fails the branch's step at once, and so does a refusal that lasts past the deadline. ## Placement [#worktrees-and-placement] A `spawn` may bind its agent to a **logical worktree** (`spawn("builder", { worktree: "wt-1" })`): the handle carries the id, and the run enforces the one rule the language states about it: two agents never share a worktree concurrently. The validator rejects the literal case up front (L3022: two branches of one concurrent scope spawning into one literal worktree, named branch functions included), and the runtime guards the rest, computed ids included: a spawn claims its tree before it submits, so a second spawn into a tree held by a live seat or by a spawn still bringing one up is the catchable L4008, a spawn that ends without a handle gives the tree back, and the tree is reusable the moment a holder's presence row is gone, so a discharged race loser or a crashed seat releases its tree with no bookkeeping. A spawn the endpoint refuses at accept is the catchable L4000 (L4001 when the refusal is the endpoint's seat capacity), and one whose seat never came up is L4002. A refusal that states the command did not run is answered before it gets that far. In a space served by more than one manager the resolve and the invoke are separate trips through the same anycast queue, so a run's call can reach an instance it did not resolve against, and that instance refuses ahead of any effect. The run drops its resolved handle, re-describes and re-issues, for a bounded number of attempts; after them the refusal surfaces as the effect's own failure and still states that nothing ran. A spawn that names a `placement` addresses one instance by name, so a refusal from it is that incarnation answering about itself and is never re-issued. When the spawn also names `cwd`, that manager resolves the existing absolute directory to its canonical host path before accepting the spawn. A missing, relative or non-directory path refuses with no seat and never falls back to the manager workspace. One hosted run may name one placement instance; a program naming several is refused instead of widening one run credential across hosts. The run's spawn names its run, so on a static mesh the placed manager reads the run's admission and revocation marker and bounds the helper as the hosting manager does. A user-auth run cannot place a spawn on another instance: its signerless host refuses the program when the run starts. Placement is accepted only from an object-literal spawn option bag whose `endpoint` and `instanceId` are string literals. A computed option bag, a computed placement field, or a spread in either object refuses at `run-start`. The option argument may be absent, and an object-literal bag without placement is accepted when it names no `cwd`. A spawn that names `cwd` must also name `placement`, because that directory belongs to one host. Without placement it refuses with L4000 before submitting a spawn. Read the manager instance with `cotal ps`, then put its full instance ID into the program as a literal: ```js const worker = await spawn("worker", { placement: { endpoint: "manager", instanceId: "abcdefghijklmnopqrstuvwxyz" }, cwd: "/home/operator/project" }) ``` Replace both example values with the manager instance and an existing absolute directory on that manager's host. No manager is chosen by class anycast for this spawn. ## Recovery cleanup [#recovery-cleanup] A turn handoff across worktrees is the L4004 described above. Recovery keeps these honest: a resumed run reseeds its roster, holders and handoff memos from its own journal, and the driver re-issues any recorded-but-undischarged cancellation at adoption, before the engine performs a new step, so a loser a crash left alive does not keep its seat or its tree while the resumed run works on. The same sweep withdraws a cancelled branch's relays and undelivered notices: a notice waits on the run for its addressee's next turn, so a decision the run cancelled would otherwise arrive at an agent with nothing to distinguish it from one that stood.