Skip to content

The requester authors the commits, the bot commits and opens them, and a run keeps the requester who started it ​

The ask. Decide (the maintainer, before the plan is written): every commit a run makes is authored by the person the run is for and committed by the bot's own GitHub identity; the bot stays the pull request's opener; the GitHub login a person is bound to is written by a holder of a dedicated grant, never by the person alone (amended 2026-09-19: the email-match path was dropped — see "The binding's trust"); before the bot opens or edits a pull request it rewrites the identities on the commits the run pushed to the requester's and its own, so no other identity ever reaches a pull request it opens; and the requester of a run is the sender of the event that started it, for the run's whole life. Written for an engineer who knows the actor model (authorization.md items 14 and 15, record 0042), the ship pipeline (agent-ship.md) and thread admission (thread-admission.md, record 0051). The maintainer asked for identity through to the result, never authority, and for a thread takeover to be unable to change whose name is on the work.

Success criteria: (1) a commit a run makes shows the requester's avatar and login on GitHub, committed by the bot, and the pull request names the requester as assignee; (2) the bot's GitHub permissions are unchanged: no person's token is held, minted or exchanged, and the App opens, edits and comments on the pull request; (3) no thread reply, prompt text or command the model runs can put a person's identity other than the requester's or the bot's on a commit of a pull request the bot opens or edits, except a person already on the branch before the run, and that only on a commit that reproduces their earlier commit's message and author date, the residual the rewrite's Boundary names; (4) a person with no binding is attributed to nobody, as today, on every plane; (5) a person cannot bind themselves, one login binds to at most one person, and a binding survives a login rename without following a stranger who takes the old name.

TL;DR ​

Every commit Switchboard makes today is switchboard-bot's, an unregistered login anyone could claim, while the run record already names the person on every child, steer and resume: identity stops one hop short of GitHub. The bet is git's author/committer split, filled by the plane and made true by the bot: the run's environment carries the requester as GIT_AUTHOR_* and the App's own id-anchored identity as GIT_COMMITTER_* before the model runs a command, and before the bot opens or edits a pull request it reads the commits the run pushed over the API and rewrites any whose author, committer or co-author trailer is not the requester's or its own, so the guarantee rests on the bot's write, never on the model's obedience. The binding from a Slack person to a GitHub login is one config key that a new identity:write grant writes; a me write of it is refused, because a self-claimed login is the impersonation this design exists to prevent. Decided: author, committer, opener, the rewrite over the run's own commits, the grant, and one requester rule (the sender of the first event, every later sender named in the transcript) replacing three renderings. Amended 2026-09-19: the guessed fact — that GitHub links a commit only to an email the account has confirmed — failed its test, so the email match is dropped; the grant binding is the one trusted write, and a GitHub OAuth link ("connect your GitHub" on the dashboard) is the third path, in a record of its own.

Today at 95a98b3d ​

You would expect a run's requester to stop at the run record; it does not. userId, userName and authenticatedAs are copied onto every spawned child, every ship coding and review child, every steer, the durable inbox row, the resume message and the run row, a dozen files (spawn.ts, ship.ts, handOff.ts, lineage.ts, inboxMessage.ts, admission.ts, provision.ts, resumeLaunch.ts among them). You would expect that identity to reach the commit; it stops at the sandbox wall: three images and E2B's setup set user.name switchboard-bot (switchboard-resident on the resident) with a users.noreply.github.com address for a login nobody holds, and the coding child's post step opens the pull request with the App's installation token from a body rendered without the requester (codingPrPostStep.ts, githubPulls.ts). You would expect the resident to differ from the sandbox on environment; it does not: all three planes take a per-exec env in the request body, the resident through validateEnvNames beside its own GIT_TERMINAL_PROMPT (deploy/cloudflare-resident/worker.ts, src/execution/resident.ts), the sandbox and E2B through one resolver, githubEnvs, that hands out GH_TOKEN per identity (factory.ts). You would expect the person's config scope to hold an identity; Scope holds settings only (agent, model, effort, verbosity, instructions, harness, review, intake and the like), and config set me is every person's to write (config.ts, commands/config.ts). You would expect one rendering of another sender's words; there are three: the unit-thread fold prints <sender>: <text> (admission.ts), the live steer hands the model bare text (threadAdmission.ts followUpPrompt), and the fresh turn after a run ends takes the last sender's identity (mergeFollowUps, settle.ts). The survey is Appendix A.

The shape ​

This is git's author/committer split, used as designed and applied to an agent. Author is the person a change is attributed to; committer is who put it in the repository. A patch applied with git am is authored by its writer and committed by the maintainer; an edit in GitHub's web editor is authored by you and committed by GitHub <noreply@github.com>; a rebase rewrites the committer and keeps the author. Here the requester, the person a run is for (userId), authors every commit the run makes, and the App's bot user commits it. The closest known shape is GitHub's web-flow commit. The one way this differs: the author did not type the change, a model did on their behalf, so a Co-Authored-By trailer names the agent, and the bot, not the model, is what makes the identities true, because the model runs git and a prompt is not a guarantee.

An identity pair is an exact (name, email); nothing here matches a name or a login alone. The bot pair is the App's bot user with its id-anchored address, <slug>[bot] <<id>+<slug>[bot]@users.noreply.github.com>, the form GitHub Actions commits with, from the resolveGithubIdentity read the bot already makes; the requester pair is <login> <<id>+<login>@users.noreply.github.com>. Three pieces carry it. The author binding is one key in a person's user scope, users.<slack id>.github: { login, id }, written by a holder of the new identity:write action (amended 2026-09-19: the email match was dropped after its test failed), and refused on the person's own me scope. The author env is the requester pair as GIT_AUTHOR_* and the bot pair as GIT_COMMITTER_*, set by the plane in the per-exec environment every plane already takes. The identity rewrite runs in the coding post step before the pull request is opened or edited, and in ship's recover path: the bot reads the run's commits, those on the branch and not on its base at the pushed tip, less those already on the branch when the run started, and rewrites every one whose author pair is not the requester's, the bot's or a pair already on the branch at the start, whose committer is not the bot pair, or whose trailers name anyone else, over the Git Data API (same tree, same parents, foreign trailers dropped), then moves the branch to the rebuilt tip and opens. The model's deviation is one audit line and one clause in the reply; nothing is asked of a model to fix it.

One trace: a teammate steers, the model overrides the author, one name lands on the work ​

Ivy types in acme/api: fix the flaky retry test in Slack. The router picks ship; the run is created for slack:UIVY; the generated one-unit plan spawns a coding child carrying the same userId, userName and authenticatedAs.

  1. The child's dispatch reads Ivy's binding, { login: ivy-dev, id: 4242 }, and the bot pair. The per-exec environment gets GIT_AUTHOR_NAME=ivy-dev, GIT_AUTHOR_EMAIL=4242+ivy-dev@users.noreply.github.com, the bot pair as GIT_COMMITTER_*, and GH_TOKEN. The branch is new, so the run's start state is "nothing on the branch".
  2. Raj replies in the unit thread, "also bump the timeout", while the child is live. Admission checks that Raj may run the coding agent, as today, and steers the text in as Raj: also bump the timeout. The requester is still slack:UIVY; the environment is unchanged; the model knows who asked for what.
  3. The model commits twice; the image's prepare-commit-msg hook appends Co-Authored-By: <bot pair>. On the second commit the model runs git commit --amend --author="Raj <raj@example.com>", then pushes fix-retry.
  4. The child submits its description. The post step reads the paginated compare main...fix-retry: two commits, none on the branch at the start, so both are the run's. a1b2 is the ivy-dev pair, committed by the bot pair, trailer the bot pair: kept. b2c3 is authored Raj <raj@example.com>, unlinked: rewritten.
  5. The bot posts a new commit object with b2c3's tree and message, parent a1b2, author the ivy-dev pair with b2c3's author date, the trailer kept, and the bot pair as committer, then moves refs/heads/fix-retry to it. It re-reads the compare: both commits are the requester pair and the bot pair. One [identity] line records "1 of 2 commits re-authored from Raj raj@example.com".
  6. The bot opens the pull request as switchboard[bot], body rendered from the description plus one bot-written line, "Requested by @ivy-dev in Slack; steered by Raj", reads back head.sha equal to the rebuilt tip, and adds ivy-dev as assignee after GET /repos/acme/api/assignees/ivy-dev answers 204. The reply names the pull request and the one re-authored commit. GitHub shows Ivy's avatar on both commits; Ivy is notified; Ivy can approve, because she is neither the pull request's author nor its last pusher.
  7. The review child runs for the same requester and comments as the bot. After the unit ends, Raj replies first and Ivy second; the fresh turn runs for Raj, names Ivy inline, and its rewrite covers only the commits that turn pushes; Ivy's two commits were on the branch at its start and are never touched, even if that turn rebases them.

The property: nothing Raj wrote and nothing the model ran changed whose name is on the work the run made, and the guarantee is a write the bot makes, not a request the model honours.

The difficulty map ​

  1. The binding's trust (section below). A self-claimed login is the whole attack; the email match's trust rests on one fact about GitHub the docs do not state, tested before the unit.
  2. The identity rewrite (section below, most work). Which commits are the run's when the model rebased, what the rewrite keeps, the head pin, and the boundary it does not cover.
  3. The author env on every plane (section below). One path now that the resident's per-exec env is known; the bot pair replaces an unclaimable address.
  4. One requester for the run's life (section below). Three renderings become one; the fresh turn's identity moves from the last sender to the first.

The binding's trust ​

The natural home for a person's GitHub login, the user scope config set me writes, is open to every person by the is-self row on config-scope { user }. A login written there by its owner is harmless; a login written there by anyone else authors every one of their runs as that person. And config:write, the obvious gate for an admin write, is the channel-config right: grantable by name to a plain user, to a whole surface, to a token. Identity is what this design carries, so identity gets a grant of its own.

One write is trusted (amended 2026-09-19; two before — the note below). A grant binding is users.<slack id>.github set through config set user --user <slack id> --github <login>, admitted by a new row identity:write on config-scope { user } with has-grant(identity:write), an action held by all and by name and by no baseline; a holder may bind themselves, since the grant is the trust. The person's own me row refuses the key on set and preserves it on clear, with one sentence, "your GitHub login is set by an identity admin; it is not yours to type". The write resolves the login to its numeric id (GET /users/<login>), stores { login, id }, and from then on resolves by id (GET /user/<id>), because a renamed login answers 404 and is claimable; a config.yaml entry may name a login alone and is resolved once, at first use, to the same shape. One validator over config.yaml and the runtime overrides together refuses a malformed login, a [bot] login and a login or id under two people, naming both.

Amended 2026-09-19. The record proposed a second trusted write, an email match: the bot would read an unbound requester's Slack profile email and bind the one login GitHub links to commits authored with it. Its trust rested on one guessed fact — that GitHub links a commit only to an email the account has confirmed — and the test the record scheduled failed: GitHub links a commit to an account through an unconfirmed email address (receipt), so anyone who learns a person's email can author commits GitHub attributes to them and the match's trust chain is broken. The email match is dropped. The grant binding above is the one trusted write, and a GitHub OAuth link ("connect your GitHub" on the dashboard) becomes the third path, in a record of its own.

Invariants: a config set me --github on any surface is refused at the door; a config clear me leaves the key; a login or id under two people fails the load naming both; a binding whose stored id no longer answers to its login is refused, not followed; no policy rule reads the binding.

The alternative this beat is the OAuth link, "connect your GitHub": a login flow, a redirect route and a refresh token per person, a new secret class. The grant binding proves the same fact with no token and no flow (amended 2026-09-19: with the email match dropped, the OAuth link is no longer the fallback but the scheduled third path, in a record of its own).

The identity rewrite ​

The model runs git. The environment makes the correct identities what happens when the model does nothing, and env beats every git config level, so a wrong identity takes an explicit --author, a GIT_COMMITTER_* override, a user.* rewrite, or a -m "Co-Authored-By: …" line. Those are deviations, whoever caused them: the model drifting or prompted into it by text in the repository or the thread, a teammate steering it there, or anyone else with push to the branch. The bot corrects them where it already acts on the branch, over the API, because a sandbox hook can be pointed elsewhere with core.hooksPath and a refusal that asks a model to run one deterministic command spends a whole round on nothing.

The run's commits are the commits on the branch and not on its base at the pushed tip (the paginated compare base...tip), less those already on the branch when the run started. Judging against the base, not the head observed at the start, is what keeps a rebase or merge onto a newer base out of the range: the base's commits are on the base. The start state is the compare base...start the dispatch records when it attaches or clones; a pull request ship adopts, an earlier round, an earlier requester's work are in it. A run that rebases those commits gives them new shas, so they enter the range as the run's; a rebase keeps a commit's message and author date, so the rewrite recognizes such a commit by its start fingerprint, the (author pair, author date, message) of a start commit, keeps its author, and normalizes the committer, which the rebase already made the bot pair through the env. A start pair on any other commit is not allowed: a start pair alone would let the model author new work as a person already on the branch. The allowed identities for a run commit are the requester pair and the bot pair for authors, plus a start pair on a commit carrying that pair's fingerprint; the bot pair alone for committers; the requester pair and the bot pair for Co-Authored-By trailers, other Co-Authored-By lines dropped and every other trailer kept. A merge commit in the range is a commit with two parents and the same rule. A requester with no binding is held to the bot pair and the fingerprints. Pairs are exact, because GitHub links by email and displays the name: Ivy Real Name <bot address> and Raj <ivy's address> are both rewritten. A compare reporting more than 300 commits is unreadable: the bot opens nothing and says so; GitHub itself stops an unpaginated compare at 250, and a coding round pushes a handful.

The rewrite is the Git Data API: for the first offending commit and every descendant in the range, POST /git/commits with the same tree and message (foreign trailers removed), the rebuilt parents (both, for a merge), the author corrected with its original date kept, and the committer sent explicitly as the bot pair, because the API defaults the committer to the author it is given; the env spells the same pair, so the bot's own commits never fail their own rule; then PATCH /git/refs/heads/<branch> with force to the rebuilt tip. The clean path costs two API calls, the compare and the head read; a rewrite adds one per commit from the first offender plus the ref move and a re-read. The bot re-reads the compare and requires it clean; a head that moved meanwhile (a process the run left behind; the resident's credential file outlives an exec) is rewritten once more, and a third disagreement is unreadable. After the open or edit the bot reads head.sha and requires the rebuilt tip. The reply names the pull request and, when anything was rewritten, how many commits and from what; an [identity] audit line carries the same. The model's checkout is now behind the branch; the next round's attach or clone takes the remote tip, as it does after any force-push today.

Two sites: the coding post step, before openPullRequest or the edit of an existing pull request, which is where every coding-shaped round of a ship and every plain coding run opens its pull request; and ship's recover path, which opens a pull request itself after a dead coding child. A review child never pushes, so a round's end needs no read of its own.

Invariants: no commit on a pull request the bot opened or edited carries an author outside the allowed set for its run, a committer other than the bot pair, or a co-author outside the two pairs; a commit on the branch at the run's start keeps its sha unless the run itself rebased it, and keeps its author while its message and author date are unchanged; a rebuilt commit whose author already passes keeps that author; a rewrite changes trees by nothing and messages by nothing but dropped Co-Authored-By lines.

Boundary: the rewrite gates the pull request, not the push. A foreign identity sits on the branch between the push and the post step, seconds to minutes, and stays on a branch whose run never reached the post step (a dead child the recover path did not reach, a run that ended without a description). A rebuilt commit is never GitHub-verified: GitHub signs an API commit only when neither author nor committer is given, and this one gives both; the model's commits are unsigned anyway. A branch under a ruleset that blocks force pushes or requires signed commits refuses the ref move, and the bot then opens nothing and says which rule; a unit's branch is normally under neither. The start fingerprint leaves one residual: a model that copies an earlier commit's message and author date onto new content, with --author and --date, attributes that content to the person who wrote the earlier commit; the attribution reaches only a person already on the branch, and a reworded rebase of a start commit is re-authored to the requester, the price of closing the wider hole. A legitimate second human co-author is not carried; that is a later feature.

Alternatives beaten. The bot running git rebase --exec … --reset-author itself in the run's workspace after the model's last command: the post step already runs git there, but the recover path has no workspace (the child is dead), a rebase flattens merges unless told otherwise, and two mechanisms for one rule is one too many; the API rebuild is the same code on every plane and on the recover path, keeps every parent, and needs no workspace. Refusing and asking the model to reset the authors in a fix round: one deterministic command bought with a model round, a second-miss ending and a branch delete. Verifying at the git door as the pack streams through: the door forwards a pack whose objects are deltified against objects it does not hold. Judging the range from the head observed at the start: a rebase onto a newer base drags the base's commits in.

The author env on every plane ​

All three planes take a per-exec environment in the request body: the sandbox and E2B from one resolver, githubEnvs, the resident from the same body field the bot already fills, injected through validateEnvNames. The author env is four variables added there: the requester pair as GIT_AUTHOR_* for a bound requester with a write identity, the bot pair as GIT_COMMITTER_* for every write identity, and the bot pair as the author too for an unbound requester, so no run is left to the image's identity. The images keep a user.* fallback for a command run with no environment, and its address moves off the GitHub domain, so switchboard-bot@users.noreply.github.com, a login nobody holds and anyone could register, stops naming the bot's commits. An unbound person's commits therefore change bytes (the App's bot user instead of the image's name) and not attribution: nobody is named, as today. The local executor takes no resolver and is not a production plane.

Invariants: a read or none identity carries no git identity variables; the resident never reads an identity from a request header; no image's user.email is a GitHub address.

One requester for the run's life ​

Everything above derives the author from the run's requester, so a hole in the requester rule is a hole in the authorship. There is one: when a run ends with replies nobody consumed, the fresh turn that runs them takes its identity from the last sender, so the last person to type in a thread authors the next round's commits; and a live steer reaches the model with no sender, so it cannot tell the requester's words from a teammate's. Record 0051 fixed the rule for a thread a ship unit owns: the owner's requester stays, and every event is attributed to its sender. This record extends it to every thread.

The requester of a run is the sender of the event that started it: the first message of a thread, the parent's message for a spawned child, the first unconsumed reply for a fresh turn. Every other sender appears in the transcript by name through one renderer, <sender>: <text>, used by the live steer's prompt and by the fresh turn's merged message; the fresh turn's identity is the first reply's, credential included. A steer's sender still passes the live agent's allowlist and their text still runs in the requester's run; a fresh turn is gated on its requester's actor, so a later sender with wider grants lends it nothing. A steerer's instructions become the requester's authored commits by design: the requester owns the thread, and the pull request's requested-by line names the steerers.

Failure mode: the first sender may not run the agent the replies were addressed to while a later one may. The fresh turn is refused for the first sender with the refusal every unauthorized reply gets. That is the correct answer and a change from today.

Why not X ​

Why not tell the model to write Co-Authored-By: <person>? A prompt is a request, not a mechanism, and a trailer is a footnote GitHub renders small; the author field is the avatar on every commit and the contributors list. Trailers are still normalized, because they render avatars too.

Why not open the pull request as the person? That needs a user-to-server token acting as the person, held and refreshed by the bot: authority carried across, the line records 0042 and 0053 drew. It also breaks the workflow: GitHub forbids approving your own pull request, so the requester could no longer approve.

Why not the committer as the person too? The committer says who ran git; the person did not. The bot as committer is how a reader tells agent work at a glance, and what the web-flow and git am conventions do.

Why not config:write for the binding? It is the channel-config right, held by name by plain users, surfaces and tokens; any holder could bind any unbound person to any login.

Boundaries ​

No user token exists, and nothing here changes what the App may do. A relayed message (authorization item 14) and a bound credential (item 15) already name the person as userId and get the author without a special case; an unbound credential authors as the bot pair. A pull request ship adopts is judged against that pull request's own base. The git door's default-branch rule covers the cold planes; the resident keeps its credential file and the prompt's branch rule.

What would change our mind ​

The guessed fact (GitHub links only confirmed addresses) was tested by hand and failed (amended 2026-09-19, receipt above): the email match is dropped and the OAuth record is scheduled. The id-anchored noreply rendering the user is verified with one hand-made commit. Reversibility: the env and the rewrite are two switches; removing them returns every commit to the bot's authorship with nothing to migrate.

Rollout ​

Four units, each one pull request through the review loop, tests first. The requester rule first. Then the binding: the key, the grant, the row, the validator, the refused me write (amended 2026-09-19: the email match was dropped after its test failed). Then the rewrite at its two sites with the bot pair as the whole allowed set, proving it on every run before any person can be named. Last the env on the three planes, the image fallback address and the hook, after the rewrite's release is live. The plan is the requester-authors-the-commits plan.

Open questions ​

QuestionOwnerResolves itBefore
Is a person with no binding told once, in their thread, how to get one?the maintainerthe first live run of the env unit under quiet verbositythe env unit's release

Validation criteria ​

Every criterion is a [gap] until its unit binds it; the plan's Verification Contract carries them, one row per proof, with the two human-gated receipts (the Git Data API writes, the noreply render; amended 2026-09-19: the unconfirmed-address test ran and its receipt is above, and the commit-search receipt left with the dropped email match) and the live receipts per environment. The living specs each unit amends (thread-admission, authorization, routing-and-config, agent-coding, agent-ship, pr-description, execution, resident-repos) bind the tests by name in the same pull request.

Appendix A: the survey at 95a98b3d ​

FactWhere
The three identity fields are copied onto children, steers, inbox rows, the resume message and the run row, in a dozen files; the in-memory follow-up drops authenticatedAssrc/core/dispatch/spawn.ts, ship.ts, provision.ts, admission.ts (followUpOf, steerRun), src/core/coordinator/handOff.ts, src/core/dispatch/lineage.ts, src/core/runLedger/inboxMessage.ts, src/core/resumeLaunch.ts
Authority decides on the credential's actor, identity on the personsrc/core/authz/actor.ts (chatActorOf), authorization.md items 14 and 15
Every image sets the bot as git user with an address for an unregistered loginDockerfile, deploy/cloudflare-sandbox/Dockerfile, deploy/cloudflare-resident/Dockerfile, src/execution/e2b.ts
The coding child's post step opens or edits the pull request with the installation token from a body the agent never authors; ship's recover path opens one after a dead coding child; the bot already writes refs over the Git Data APIsrc/core/codingPrPostStep.ts, src/execution/githubPulls.ts, src/channels/adminCoordinator.ts, agent-coding.md item 2, agent-ship.md item 10
The sandbox and E2B environment comes from one resolver; the resident injects an exec body's env through validateEnvNames; x-env-* headers are ignoredsrc/execution/factory.ts (githubEnvs), src/execution/resident.ts, deploy/cloudflare-resident/worker.ts, src/execution/residentExecEnv.test.ts
The user scope holds settings only; config set me is admitted by the actorKinds: [user] command row and the is-self scope row; config:write is grantable by namesrc/config.ts (Scope), src/core/commands/config.ts (meIdOf), src/core/authz/policy.ts, src/core/authz/grants.ts
Slack gives a person's email (uncached, no timeout) and a bounded reverse lookup under users:read.emailsrc/channels/slack/lookups.ts (resolveUserEmail, resolvePersonByEmail), src/channels/slackCatchUpStatus.ts
The bot resolves its own GitHub identity and numeric idsrc/execution/githubApp.ts (resolveGithubIdentity)
Delivery counts an agent push by a [bot] login or an agent co-author trailersrc/core/delivery.ts
Three renderings of another sender's wordssrc/core/dispatch/admission.ts (foldThreadEvents), src/core/threadAdmission.ts (followUpPrompt, mergeFollowUps), src/core/dispatch/settle.ts (prepareFreshTurn)
A follow-up's sender must be allowed to run the live agent, for a person's reply and a run's steer alikesrc/core/dispatch/admission.ts (admit, steerRun), thread-admission.md
The git door refuses a default-branch push on the cold planes; the resident is outside itrecord 0048