Repository navigation
Specs operational safety
This spec carries the Runtime's operational safety rules from the archived team-file, dialog,
delegate, seat-account and agent-setup designs: how a temporary seat and a worktree end; the one
writer over dialog state and the recovery behind team answer; the Git environment a launched
child receives; and the author and committer checks over commits that claim a seat.
It separates what is built at v0.5.0 from what is not. Seat lifecycle, launch and the state record are launch's; the watch's authority and its reports are seat-state-and-watch's; capability admission is grants's. The declared account fields and the values they map to are accounts and gates §3.4's; this spec says when those values are set and what is cleared, and repeats none of the mapping.
None of these rules contains a process. Every seat runs as the owner's OS user, so the end condition, the lock and the clearing below are mistake guards, not a boundary against a hostile process of the same user.
-
End condition: either
result:<path>, which holds when the named path exists, ormerged:<branch>, which holds when the branch's own commits were non-empty at an earlier pass and are empty now. The path is relative to the project root and read lexically. -
Own commits: commits reachable from a branch head and not from
origin/<base>, read from the branch's own history after a fetch that succeeded in the same pass. -
Free: the watch's reading that a seat may be closed without input: herdr's
idleordonestatus, or anidlescreen for a profile herdr cannot report faithfully, with no dialog, question, unsent composer or working turn — so an idle seat whose CLI is still alive is free. Only a temporary seat is held to this test; a worktree is not. -
Worked: the seat has been observed
workingat least once since its rules were delivered. Only a temporary seat is held to this test; a worktree is not. - Seat lock: one exclusive lock per session and seat, held for a command span.
-
Manual marker: the archived dialog design's prose word
manual-owner-control; at v0.5.0 the marker is stored aswaiting.manual: trueon thewaiting-ownerrecord (launch §3.5). -
Recovery record: the
waiting.statevaluetrust-sent-recovery, written before an answer key is sent (launch §3.5). -
Declared seat account: the
github.account,github.author_name,github.author_emailandgithub.keychainfields of accounts and gates §3.4.
result:<path> holds when the lexical path, resolved under the project root, exists.
merged:<branch> holds when the branch's own commits, read with the same fetch in the same pass,
were non-empty at an earlier pass and are empty now.
- A branch with no own commit is never merged, including a fast-forward-only update of the base branch. A branch committed and merged between two passes is reported as merged, not removed: the earlier non-empty reading is missing.
- A failed fetch, or a branch that vanished, removes nothing and closes nothing.
- A squash or rebase merge is not followed by history: its end is reported as not provable and nothing is removed.
- A temporary seat's condition is acted on only when the seat has been
workingat least once since its rules were delivered and isfree, so a copy that never started work is not ended by its own creation and a seat that is not free is left as it is. A worktree is judged without either test (§3.3).
--until is validated lexically before anything is created. The archived requirement rejects an
absolute path, ~, every .. path segment and any symlink in the path; only a lexical
descendant of the project root is accepted and the link is not followed. At v0.5.0 the first
three are built and the symlink rejection is not: src/end/until.ts:resultPathProblem checks the
path as written, never stats it, and accepts a contained name that is a symlink, and the
existence test that follows it (src/watch/end.ts:readEnd) follows the link.
A merged: value is validated with Git's ref-name rule: git check-ref-format --branch must
print the name back unchanged, and a value beginning - is rejected before Git is asked. The
name is passed to git rev-list and git diff after --end-of-options; a bare -- would make
it a path.
One command starts a temporary seat:
team add --temporary --like <seat> --until <result:path|merged:branch>. There is no copy-start
or copy-stop pair; team remove <temporary-name> is the stop. team restart refuses a temporary
seat and names that pair instead.
The name is the liked seat's name, -tmp, and the next free seat number under the
seat-numbering setting's width and separator (worker-tmp-01 at the shipped default); a name
running in the session or listed by herdr holds its number too. The state record at
sessions[<session>].seats[<copy>] carries temporary: { like, until, task?, own_commits? },
plus the ordinary pane, workspace, launched identity and stage. The team file is unchanged.
The archived contract further refuses a copy of a seat that is signed off, parked or stopped —
signed off stays off — and has the form consume the signed ceilings limits.seats,
limits.temporary and limits.vendors.<vendor>, never a seat count. None of that refusal is
built at v0.5.0. temporarySeat (src/commands/add.ts:1053–1086) selects the liked seat from
the loaded team file and sets stopped: false, so a source whose record is stopped (the
sign-off team restart names) or parked is copied. The ceilings come from ceilingsOf(team)
(add.ts:404), which reads the live team.limits (src/approve/approval.ts:21), not the
signed approval's stored snapshot. §7 records the divergence.
The watch judges the recorded end and reports it once. It writes own_commits back when the
proof first turns non-empty. The archived contract is that the watch closes the copy when the
validated end holds and the seat has worked and is free, as session end and down do, and a
later up never recreates it. At v0.5.0 that close is not built: the watch reports the end and
then says ${name}: its record is not an approved seat; left as it is (the owner cleans it: team remove ${name} --abandon), so the owner's team remove is the stop at the tag.
The team file's workspace.remove is on-merge (the default) or manual. A worktree record in
the session state carries path, branch, own_commits?, seat? and setup. Its end is
merged:<branch> under §3.1's proof, with the same rule that no own commit means never merged.
team worktree remove refuses rather than guesses, in this order:
| Refusal | Result id | When |
|---|---|---|
| invalid task name | worktree.remove-task |
the task name is not a valid task name |
| no record | worktree.missing |
no worktree of that name is recorded |
| another session | worktree.elsewhere |
the record belongs to a different session; nothing was removed |
| occupied | worktree.occupied |
a seat is recorded in the worktree; team remove that seat first |
| unreadable | worktree.unreadable |
the worktree cannot be read |
| dirty | worktree.dirty |
uncommitted or untracked files are present |
| path mismatch | worktree.remove-failed |
the recorded path is not the path workspace.path resolves to; nothing was removed |
| wrong branch | worktree.remove-branch |
--branch names a branch other than the one the worktree is on; nothing was removed |
| unpublished | worktree.unpublished |
the branch has commits on no remote branch |
| removal failed | worktree.remove-failed |
git worktree remove itself failed; nothing was removed |
A recorded folder that is already gone is not a refusal: the check sits after
worktree.occupied and before worktree.unreadable, and the command runs git worktree prune,
deletes the state record and exits 0 with worktree.record-gone, deleting no branch.
A successful removal says it kept the branch; the branch is never deleted. Ignored files in the worktree are deleted with it, as the command's own usage line says.
On on-merge, the watch removes a worktree only when the end holds, no seat is recorded in it,
and remove itself would not refuse; the judge applies no worked or free test, and the
removal is logged with caller watch. A failed removal says worktree <task> was not removed.
On manual, only the command removes. The watch never calls add, approve or trust.
One exclusive lock exists per session and seat at <state dir>/seat-locks/<session>/<seat>. The
lock's token is <pid> <nonce>, staged complete in a private temporary file and taken with a
hard link, so a partially written token is never observable. An existing link means held.
An unreadable or unparseable lock is held: it is never renamed or removed. A stale lock, whose
process id is dead, is taken over only by winning a second exclusive link on <path>.steal and
re-reading the token before the unlink. Release compares the whole token, so a lock taken over in
between is not released by its old holder.
Two commands contend for the same seat state. A launch that opens a dialog records the manual
marker under the seat lock before it focuses the pane; at v0.5.0 the marker is the boolean
waiting.manual: true on the waiting-owner record (launch §3.5), while
manual-owner-control is the archived design's prose word. The marker is exclusive to that
launch invocation through renewed dialogs and timeouts. team answer refuses the marker and
leaves the pane to the owner.
A launch with no terminal of its own, and a delegated run, close a dialog without input: the
pane is closed, the seat is left out with its classification, the line
<seat>: left out: <classification> (no terminal for owner) is printed, and the command exits 1.
A waiting pane that vanished is never relaunched; the report is
<seat>: left out: its waiting pane is gone. The repair is team remove <seat> --keep, then
team up.
team answer <seat> trust presses only the recorded key. It holds the exclusive command-span
seat lock for the whole answer, takes two fresh reads of the pane, and compares the exact label,
version and folder on both. The folder must be the lobby on three readings: the screen's path,
the pane's own working folder, and the lobby's parent. trust is the only dialog word.
The refusal is named before anything is typed: the dialog policy is off (answer.policy); the
seat is not live or its record is not a declared seat (answer.unverified); the screen is
ambiguous (answer.ambiguous); or one of the folder, label, version and action checks differs
(answer.folder, answer.label, answer.version, answer.action).
Before the key is sent, the recovery record trust-sent-recovery is written. If the process
check fails before the send, the previous waiting-owner state is restored; a rollback that
fails keeps the recovery. After a send, a retry only observes: it never sends the key again. A
recovery stays recorded for the next answer to observe; it is not silently cleared.
The end state is that a launched seat's child process receives only its declared Git identity, clears the inherited inputs that would otherwise carry the owner's identity, and never prints or logs a value. Which fields supply which value, and the exact variable names, are accounts and gates §3.4's and are not repeated here.
The cleared end state also covers the forms Git can use to escape the declared identity: the
common SSH GitHub forms run the command false through cleared SSH inputs and
core.sshCommand=false; the owner's global and system Git configuration and inherited
config pairs are overridden or cleared; the stored gh variables and the inherited
GITHUB_TOKEN, GITHUB_ENTERPRISE_TOKEN, GH_ENTERPRISE_TOKEN, GH_HOST and GH_CONFIG_DIR
are cleared, with GH_TOKEN the intended preference. These are the archived seat-account
design's controls.
Today, at v0.5.0 and at int/0.5.1 (8acb470b), none of this is built: no code sets
GIT_AUTHOR_*, GIT_COMMITTER_*, GH_TOKEN or GIT_CONFIG_* for a seat. The herdr server is
started detached in a clean environment whose allowlist is HOME, USER, LOGNAME, PATH,
SHELL, TERM and LANG, and it passes SSH_AUTH_SOCK when the owner has one. A seat launched
there therefore keeps the owner's SSH agent, and the owner's ambient Git configuration, and no
declared identity is injected.
team check applies an account rule to the selected commits. It is a consistency check, not
proof: a seat can set the author and committer fields itself. Selection follows identity.since:
unset excludes nothing; set excludes that commit and every commit reachable from it, leaving its
ancestors out of the selected range.
| Commit kind | Account rule |
|---|---|
| merge (more than one parent) | exempt |
declared human (author email in identity.humans) |
exempt entirely |
| trailer names an inheriting seat | author and committer name and email equal the declared seat account |
trailer names an identity: off seat |
no account comparison |
| trailer names no seat | no account comparison |
| squash, GitHub rebase merge, web edit, rewritten rebase | checked when the trailer names an inheriting seat |
A failure says author is not the declared seat account or
committer is not the declared seat account. The line names expected configuration, never a
token. check stays read-only and exits 1 on a finding.
At v0.5.0 this account rule is not built: team check runs its existing signature and forbidden
pattern checks but compares no author or committer against a declared seat account.
At the release tag, the exact synopsis lines from contract/cli.json:
team add --temporary --like <seat> --until <result:path|merged:branch> [--worktree <task>] [--model <model>] [--effort <effort>] [--dry-run] [--session <name>] [--file <path>] [--json]
team remove <name> [--keep] [--abandon] [--session <name>] [--file <path>] [--json]
team answer <seat> trust [--session <name>] [--file <path>] [--json]
team worktree new <task> [--kind <kind>] [--seat <name>] [--session <name>] [--file <path>] [--json]
team worktree list [--session <name>] [--file <path>] [--json]
team worktree remove <task> [--branch <name>] [--session <name>] [--file <path>] [--json]
There is no team copy start or team copy stop.
Result identifiers this spec asserts, all registered at v0.5.0:
| Group | Identifiers |
|---|---|
| temporary add |
add.no-like, add.until, add.result-absolute, add.result-exists, add.merged-base, add.branch-missing, add.worktree-missing, add.worktree-failed, add.temporary-flags
|
| answer |
answer.policy, answer.unverified, answer.ambiguous, answer.folder, answer.label, answer.version, answer.action, answer.recovery
|
| worktree |
worktree.remove-task, worktree.missing, worktree.elsewhere, worktree.occupied, worktree.record-gone, worktree.unreadable, worktree.dirty, worktree.unpublished, worktree.remove-branch, worktree.remove-failed
|
-
An end MUST be acted on only from its proof: own commits read after a fetch that succeeded in the same pass; a branch MUST never count as merged without an earlier non-empty reading; a failed fetch, a vanished branch or an unprovable squash or rebase MUST end nothing. A temporary seat MUST have been
workingonce since rules delivery and MUST befree; a worktree MUST NOT be held to either test. Source: archived team-file design. Proof:test/end.test.tsandtest/commands/watch-close.test.tsat v0.5.0. -
--untilMUST be validated lexically for every caller, the owner included. Forresult:, an absolute path,~and every..segment MUST be refused, and the archived requirement also refuses any symlink in the path and follows no link; at v0.5.0 that symlink refusal is not built (resultPathProblemnever stats, andreadEnd's existence test follows the link). Formerged:, Git's ref rule MUST be applied withgit check-ref-format --branch, a leading-MUST be refused before Git is asked, and the name MUST be passed togit rev-listandgit diffafter--end-of-options, never after a bare--. Source: archived agent-setup design; code at v0.5.0. Proof:test/end.test.tsat v0.5.0 proves the lexical check and the acceptance of a contained symlink, not its rejection. -
A temporary seat MUST be a state record with one start command and one stop command, a validated end and the liked seat's definition, and there MUST NOT be a copy lifecycle. The archived contract further requires that a copy of a signed-off, parked or stopped seat MUST be refused — signed off stays off — and that the form consume the signed ceilings
limits.seats,limits.temporaryandlimits.vendors.<vendor>, never a seatcount. Source: archived agent-setup design. At v0.5.0 the state-record form and the validated end are built (test/commands/add.test.ts); the refusal and the signed-ceiling authority are not:temporarySeatsetsstopped: falseandceilingsOf(team)reads the liveteam.limits, so no tag test proves them. The watch's removal of a temporary seat is not built (rule 4). -
The watch MUST close a temporary seat only when its validated end holds and the seat has
workedand isfree, and a laterupMUST NOT recreate it. A worktree MUST be removed only on the proof, unoccupied, onon-merge, and whenremovewould not refuse, and its branch MUST NOT be deleted. Source: archived team-file and agent-setup designs. Proof at v0.5.0: the judge and report and the worktree rules are proven bytest/commands/worktree.test.ts,test/worktree-remove-refusals-boundaries.test.ts,test/commands/watch-close.test.tsandtest/watch-workspace.test.ts; the watch's removal of a temporary seat is not built (the owner'steam removeis the stop at the tag). -
One writer MUST hold a seat's dialog state: a complete token taken by hard link, a corrupt lock treated as held, a stale lock taken over only through the second link and a re-read, and the manual marker recorded under the lock before the pane is focused and refused by
team answer. At v0.5.0 the marker is the booleanwaiting.manual: trueon thewaiting-ownerrecord (launch §3.5);manual-owner-controlis the archived prose word. Source: archived dialog design; code at v0.5.0. Proof:test/launch/seat-lock.test.ts,test/launch/pause.test.tsandtest/answer.test.tsat v0.5.0. -
An answer MUST hold the command-span lock, MUST check two fresh reads and the three folder readings, MUST write the recovery before the key, MUST restore the pre-send state when the send is refused, and MUST only observe after a send. Source: archived dialog design; code at v0.5.0. Proof:
test/answer.test.ts,test/close.test.tsandtest/commands/watch-close.test.tsat v0.5.0. -
A launch with no terminal and a delegated run MUST close a dialog without input only through their named paths, MUST report the seat left out with its classification and MUST exit 1; a vanished waiting pane MUST NOT be relaunched. Source: archived dialog and delegate designs; code at v0.5.0. Proof:
test/close.test.tsat v0.5.0. -
Not built at v0.5.0 or at int/0.5.1 (8acb470b). A launched child MUST receive only its declared Git identity, the inherited identity inputs MUST be cleared, no value MUST be printed or logged, and the Git forms that would escape the identity MUST run
false. Source: archived seat-account design; the accepted accounts amendment. Proof: none at either ref; the herdr server's clean-server environment and itsSSH_AUTH_SOCKpass-through are the only built pieces. -
Not built at v0.5.0 or at int/0.5.1 (8acb470b).
team checkMUST apply the account rule over selected commits: merges MUST be exempt, declared humans MUST be exempt, a trailer naming an inheriting seat MUST be compared against the declared account, an off seat or no seat MUST get no comparison, and squash and rewritten forms MUST be checked when the trailer names a seat; a failure MUST name configuration, never a token; the check MUST stay read-only and MUST exit 1 on a finding. Source: archived seat-account design. Proof: none.
-
Exit codes: every identifier in §4 is a registered v0.5.0 row of
the
answer,addandworktreegroups. This spec contributes no new row. - Operations: the commands' operations are their owning areas'; this spec adds none.
- CLI ranges: none.
-
Seat states and prompt kinds: this spec adds no
row. The waiting record's
statevalues (waiting-owner,trust-sent-recovery) and itsmanualfield are launch §3.5's, read here and not contributed again;manual-owner-controlis the archived design's prose word and no row of it exists.
- The archived agent-setup design's temporary form accepts no
--worktree; the shipped v0.5.0 synopsis accepts one and points the copy'scwdat the worktree. This draft records the divergence and does not resolve it: the archive's signed-ceiling and end rules were written for a copy without a worktree. - At v0.5.0 the archived temporary-seat refusal and ceiling authority are not built: a
stopped(signed-off) orparkedsource is copied and the copy starts, and the ceilings come from the liveteam.limits, not from the approval's stored snapshot. Rule 3's signed-off and signed-ceiling clauses are the archived contract, not a built fact, and no tag test proves them. - The archived
result:symlink rejection is not built at v0.5.0 either: the lexical check never stats and the existence test follows a link, so a contained name that is a symlink is accepted. §3.1 and rule 2 say which part is built. - At v0.5.0 the watch does not remove a temporary seat whose end holds; it reports and leaves the
owner's
team removeas the stop. Rule 4's close is the archived contract, not a built fact. - The current accounts table's Git configuration clearing is four
GIT_CONFIG_COUNTpairs; the archived seat-account design's control list also setscore.sshCommand=false. Whether the accepted table's four pairs are the whole end state or the fifth control is missing from the table is open for the accounts text's owner. - The archived seat-account design's residual limits stand: a failed resolver leaves the owner's shell; a restored seat that TeamCLI did not launch is injected with nothing; a shell startup export, an agent CLI's own stored login, and same-user inspection are owner-accepted limits.
- Deferred as before: a separate OS user or sandbox per seat, per-seat accounts, a GitHub App token, commit signing, and other forges.
- The archived dialog design's deferred policy items are not settled here: permission-dialog
policy beyond
trust, vendor-notice handling, and trust-flag variants. - Rules 8 and 9 have no proof; they need a built head and a measurement before this draft claims any Today row.
-
Draft (2026-10-19): lifted the end conditions and worktree removal rules, the temporary-seat
form and ceilings, the dialog-state lock and manual marker, the answer's checks and recovery,
the child Git environment and the commit-account check from the archived team-file, dialog,
delegate, seat-account and agent-setup designs; read
src/commands/add.ts,src/commands/worktree.ts,src/commands/watch.ts,src/commands/answer.ts,src/launch/seat-lock.ts,src/launch/plan.tsandsrc/herdr.tsat v0.5.0, and the identity and commit checks again at int/0.5.1 (8acb470b). Built and not-built rules are separated; the identity mapping is linked, not repeated. -
Review fold (2026-10-19): split the archived
result:symlink rejection from the built lexical check and named the gap in §3.1, rule 2 and §7; replaced bare--with--end-of-optionsandcheck-ref-format --branchformerged:; split the archived signed-off refusal and signed-ceiling authority fromtemporarySeat/ceilingsOf(team)in §3.2, rule 3 and §7; restored the exactworkedandfreeconditions of a temporary end and their absence fromjudgeWorktreein §2, §3.1, §3.3 and rules 1/4; addedworktree.remove-branch, madeworktree.record-gonean early exit-0 pruning branch and alignedworktree.remove-failedwithsrc/commands/worktree.ts; recorded the manual marker aswaiting.manual: trueon thewaiting-ownerrecord in §2, §3.4, rule 5 and §6, pointing at launch.md §3.5; and added BCP 14 MUST/MUST NOT keywords to rules 1–9.
Generated from docs/specs/operational-safety.md at v0.5.2 (1d1224a4) — edit the repository, not this page.