HTTP ingress: authenticated channel adapter #3
The core is channel-agnostic: a channel is only a transport that turns an inbound request into an IncomingMessage, calls the core dispatch(), and provides a ChannelIO to reply through. Slack (adapter #1) and the CLI (adapter #2) already prove this; HTTP is adapter #3 — the same request from an HTTP client reaches the same agents under the same permissions, satisfying the AGENTS.md ≥2-implementations invariant for the channel boundary. MCP uses the same core and job lifecycle.
HTTP is single-shot request/response, unlike Slack's long-lived threads. The endpoint is our own service (not behind a platform's auth), so it owns its security in-band: bearer-token auth, fail-closed, constant-time compares, identity mapped from the token into the namespaced IncomingMessage so the existing canRunAgent/canUseRepo gates apply unchanged.
- Code:
src/core/organization/admissionTrace.ts,src/channels/githubInstallationActivity.ts,src/channels/githubActivity.ts,src/core/coordinator/checksIntake.ts,deploy/cloudflare/containerStart.ts(item 12: the startup-availability matcher, typed port-loss error and private unavailable response);src/channels/githubWebhook.ts(item 12: the check-run intake's node:http adapter);src/channels/http.ts(authenticatepure auth,handleIngressRequesttransport gating,HttpIOsingle-shotChannelIO,readBodysize cap,createIngressHandlernode:http wrapper,parseIngressTokensenv→config);src/channels/singleShotDispatch.ts(the HTTP/MCP job lifecycle);src/index.ts(wiresPOST /ingressinto the existing http server alongside the health probe; the coordinator routes beside the other/admin/*handlers; the model proxy's three paths and its bearer mint beside/mcp, item 10);src/channels/adminCoordinator.ts(item 9: the coordinator's steps behind thecoordinatorbearer —handleCoordinatorRequestpure over a parsed request,createAdminCoordinatorHandlerthe node:http adapter);src/core/coordinator/instancesRoute.ts(items 9 and 12: the pure halves of the shim's instance and event routes, both directions) andsrc/core/coordinator/contract.ts(the names, patterns, paths and key);src/core/coordinator/briefs.ts(item 9: the child's turn composed bot-side from a spawn's brief);src/core/coordinator/driver.ts(item 9: the plan runner's driver — the Workflow'srun()body over a structural step runner and a bot client, node-free);deploy/cloudflare/coordinator.ts(item 9: theShipCoordinatorWorkflow entrypoint over a narrowed env, binding the platform's step and the container to the driver),deploy/cloudflare/shared.ts(the container instance and internal origin the entry and the Workflow both address) anddeploy/cloudflare/worker.ts(POST /admin/coordinator/instances,GET /admin/coordinator/instances/<id>, the re-export the binding resolves);deploy/cloudflare/wrangler.template.jsonc(theSHIP_COORDINATORbinding). - Tests:
src/channels/githubInstallationActivity.test.ts,src/channels/githubActivity.test.ts,src/channels/githubWebhook.test.ts,src/core/coordinator/checksIntake.test.ts,deploy/cloudflare/containerStart.test.ts,deploy/cloudflare/containerStartContract.test.ts(item 12: the helper and actual fetch override);src/channels/http.test.ts;src/channels/adminCoordinator.test.ts,src/core/coordinator/instancesRoute.test.ts,src/core/coordinator/briefs.test.ts,src/core/coordinator/driver.test.tsanddeploy/cloudflare/coordinator.test.ts(item 9; the shim's plain-Node test projectdeploy/cloudflare/vitest.config.mjsruns the last). - Docs: AGENTS.md invariants 1, 2, 3, 4, How a request flows, Worker topology.
Behavior
The composed request thread key must fit the ledger's 256-character claim key after the five-character #host suffix. A longer channel/thread pair is refused before dispatch.
Pure transport, same core.
POST /ingresswith a JSON body{ text, channel?, thread?, history? }becomes anIncomingMessageand is handed to the unchangeddispatch(deps, msg, io)— no dispatcher, runner, or permission change. Identities are platform-namespaced (invariant 4), mirroring Slack:userId=http:<subject>,channelId=http:<channel|default>,threadKey=http:<channel>:<thread|default>. Before either key is composed, every suppliedchannelandthreadis validated as its own component: it must be a string, non-empty, at most 128 characters, and contain neither:nor#; the identity-pinned channel is checked by the same rule, and the body channel is still checked before a valid pin overrides it. A refusal is400and nothing is dispatched, so two component pairs cannot collapse to one key and a caller cannot forge the reserved#hostsuffix. Once the caller is identified and the body parsed, the adapter starts the request's root and stamps the messagereceivedAt, handing both todispatch(deps, msg, io, { trace })— theDispatchFnseam's optional fourth argument (tracing.md item 18); the async path's root ends when the background dispatch does.Single-shot
ChannelIO.reply()collects text and the collected text is the HTTP response body ({ reply });status()is an honest no-op handle (no live surface to edit in one shot);openThread()gives a coordinator unit a stable job thread under the request key;history()replays the optionalhistoryarray from the body, else[]. Run receipt: when the core created a run for the request — an agent run, or an inline command run such asfriction propose— the core callsChannelIO.runFinished({ id, status })once the run is finished in the registry, and the response carries it asrun: { id, status }(status∈completed | failed | stopped_soft | stopped_hard | interrupted— the last a run whose pi container was replaced under it, whose request runs again as a new run on the same handle with a receipt of its own; harness-pi.md item 16). The receipt names the run (/runs/<id>), it never carries the view token. A request that produced no run (a config reply) has norunfield. This is how the Worker shim learns which run a scheduled firing became.Bearer auth, fail-closed.
authenticate(headers, config)requiresAuthorization: Bearer <token>; a missing, malformed, or unknown token yieldsnull→401. With no tokens the endpoint is disabled →503, never open. The token map identifies{ subject, channel?, email? }; authorization comes only from thegrantsentry forhttp:<subject>, and an unlisted credential holds nothing. Starting a run requires thedispatchaction; a credential without it is refused at the header gate with403 {error:"forbidden", code:"unauthorized"}before the body is read. The channel pin sets the request scope, never a grant.Constant-time compares, no token logging. Tokens are compared with
crypto.timingSafeEqualover equal-length buffers (length guarded first); the loop checks every configured token without short-circuiting, so neither the presence nor the position of a match is a timing oracle. Token material is never logged.Input hardening. The body is size-capped at read time (~1 MB,
413before it is fully buffered); invalid JSON, a non-object body, a missing/blanktext, or a malformedhistoryentry return400; a non-POST method returns405. Malformed env token config is treated as "no tokens" (disabled), never as open.Authorization precedes body-buffering. Method (405), the disabled check (503), and bearer auth (401) are decided from headers alone (
authorizeRequest) before the body is read, so an unauthorized/wrong-method/disabled caller never buffers a body — shrinking the pre-auth request surface.Async mode (
"async": true). A machine caller (e.g. an automation dispatching a coding brief) that only needs the acknowledgement can add"async": trueto the body: validation and authorization are exactly the sync path's (the async branch is only reached afterauthorizeRequest,requireDispatch, andparseBody; a non-booleanasyncis a400, and the credential's grants and channel pin apply unchanged), thendispatch()is started, not awaited, and the response is202 Acceptedwith{ runId, runUrl, threadKey }the moment the core has created the run in the registry (the new optionalChannelIO.runStarted({ id })hook, fired at everyregistry.createsite).runUrlis<PUBLIC_BASE_URL>/runs/<id>(path-only when unset). The run continues to completion in the background: its reply text goes to the run record — never to any HTTP response — and its history record lands as usual, including for failed and hard-stopped runs (the dispatcher's own finally owns the record). The shutdown drain awaits async runs exactly like synchronous ones becausedispatch()increments the in-flight counter on its first line, before the202is written. An async request the core answers without creating a run (a config reply) falls back to the sync200 { reply }shape rather than hanging. Withoutasync(or with"async": false) the behavior is byte-identical to before.Wiring keeps health working. The
PORTserver routes/ingressto the ingress handler and every other path to the existingokhealth probe. With no tokens configured the startup log says ingress is DISABLED.The coordinator's steps — the same token map, a dedicated bearer, one policy action (record 0029, record 0031; run-history.md items 47–49; authorization.md item 2). A ship coordinator is a Workflow instance in the bot's shim Worker (
ShipCoordinator,deploy/cloudflare/coordinator.ts, re-exported byworker.tsfor the shim's own same-scriptSHIP_COORDINATORbinding;run()is the plan runner's driver, below) that holds no model, Slack or GitHub credential: its env is narrowed by type to the container binding and the token map, and a source scan holds it to that. Every fact and every child is the bot's, behind routes the shim forwards to the container like every/admin/*path (src/channels/adminCoordinator.ts; theauthorizequestion,spawn,read-record,pr-check, and the plan runner's ownplan,unit-start,branch,round,unit-end,mergeandfinish), plus one the shim answers itself,POST /admin/coordinator/instances({ id, params? }→201 { ok, id, created }; a taken id409 duplicate_instancewith the existing instance's status; a create the engine refuses502 create_failed; typed authorization denial before Workflow create isnot_attempted, while a create with lost reply and unreadable same-id status is unresolved; the pure halves insrc/core/coordinator/instancesRoute.ts), and its readGET /admin/coordinator/instances/<id>— the platform's status of one instance (200 { ok, id, status }; no such instance404 no_instance— the engine's owninstance.not_foundcode and nothing else,isInstanceNotFound; the engine failing in any other way502 status_failedby reason, never read as absence however its text reads), behind the same door, which the bot asks before it re-issues a plan (agent-ship.md item 16) and reads throughfetchInstanceStatusViaShim. The door is one bearer: thecoordinatorentry ofSWITCHBOARD_INGRESS_TOKENS(a name;COORDINATOR_IDENTITY), whose actorhttp:coordinatorthe policy table admits oncoordinator:step— aservicekind holding the grant — and nothing else does: no bearer401, a bearer without the grant403naming the grant, no token map503, on every route. The door is decided from the headers alone, in order — the step (404for an unknown one), the method (405for a non-POST), then the bearer — before a body is read, so a refused caller, an unknown step or a wrong method never buffers one (the ingress's own rule, item 6), and the bearer is looked at once per request (decideCoordinatorDoor, thenanswerCoordinatorStepover the body). The shim's instance route authenticates the bearer against the map it holds (an unknown bearer never touches the container), relays it to the bot'sPOST /admin/coordinator/authorize, and creates only on a200 { ok, subject }; any answer it cannot read is503, fail-closed.spawn{ parentInstanceId, step, preset, budget?, prompt | brief, unit? }never takes an actor from its caller: auserIdorchannelIdin the body is ignored; the requester, channel, thread, repository and branch come from the parent ship record (run-history item 49 — an unknown instance is404 unknown_instance), the child's text is the message the requester would have typed (agent:<preset> [budget:<n>] in <owner/name>: <prompt>, item 20 of routing-and-config.md), and the child is adispatch()as that user into the unit's thread (a review child's too, agent-ship.md item 5; a row written before record 0055 that names a review thread keeps it) withDispatchOptions.coordinator = { parentInstanceId, idempotencyKey: "<parentInstanceId>:<step>", base? }—basethe parent record's base branch when it names one (run-history.md item 49; the plan's,mainfor a plan read there), the branch the child's pull request targets: the child runs AT its unit branch, so the thread's binding ref is that branch itself and only the tag can say where the pull request opens (pr-description.md item 5); an instance without a base sends no field and the post-step's own resolution runs — so the agent gate, the profile gate and the repository gates judge it with that person's grants — a gate's refusal answers403with the gate's own name (agent_allowlist, …) and what the thread was told; a preset the registry does not know, theshippreset (a child is a round, never a pipeline), an empty prompt, a budget under two minutes or a step with a colon are400. Safe to retry: before anything starts the route reads the child's thread — a live run on it carrying the same key (here, or on another generation's ledger row) answers200 { runId, alreadySpawned: true }; a live run without the key (another step's child, a person's run) answers409 busynaming it; a finished run in the thread carrying the key since the instance was created (the channel's finished runs paged newest first, up to the default retention's page count) answersalreadySpawned— so a retry that lands after a quick child ended never spawns a second one; and a run that takes the thread between that read and the claim is refused by admission ascoordinator_thread_live(thread-admission.md item 8), which the route answers from the live run the same way. The route answers200 { runId, threadKey }only after the child durably reserves that same id and installs its setup finalizer, then registers; a failed reservation is403 child_reservation_failedwith no child id, and a post-ack pre-model failure finishes that original child rather than a separate door record (run-history.md, Coordinator producer identity); a dispatch that ended with no run and no gate's name is502 spawn_failed; an instance whose channel no thread can be rebuilt on is503 no_channel. Abriefin place of aprompt({ kind: contract | review | findings, unit, … }— ids only: the unit, the pull request, the review run; a brief beside a prompt, of the wrong preset for its kind, or malformed is400) is composed into the child's turn by the bot itself (src/core/coordinator/briefs.ts): a contract brief is the unit's contract from the plan, exactly the specs the unit names and the rules file, read at the base ref, with the unit's board issue (contractFromPlan; a generated task unit's from its authenticated durable unit checkpoint throughcontractFromPlan); a review brief is the review turn over the pull request and the head to pin, a re-review carrying the prior review run's findings and the coding run's dispositions from their records (prior.codingRunId), matched to the review's ids with an id it never issued named as dropped (matchDispositions,buildShipReviewTurn); a findings brief carries the review and check findings into the unit thread as a message and binds their exact IDs by value in the coordinator's durable child tag (agent:codingexplicit in the text, no contract; agent-ship.md item 7) — so no prompt text ever crosses from the Workflow, and the child's text names the unit's branch (agent:<preset> [budget:<n>] in <owner/name> on branch <ref>: …). A brief for a unit whose threadunit-starthas not opened is409 unit_not_started, a passing condition the runner asks again on; one the bot cannot compose (the plan missing at the base, a run the history lacks) is502 brief_failed, a failed spawn the machine ends the unit on as an abort naming the reason — both stampedatlike every answer.read-record{ parentInstanceId, runId, unit? }answers a run's facts —id, finished, status?, agent?, startedAt, finishedAt?, activity?, parentInstanceId, idempotencyKey?, ownerGen?and, once finished,finalReply(the lastanswerevent's text) and the typed artifacts its record carries (supplied byRunsServicefrom the store the moment it holds the record, a child whose finished row the registry still holds included — run-history.md item 21 — so a child read the second it finishes answers its verdict):pr(a coding child'spr_opened: number, url, created),verdictandreviewHead(a review child's; run-history.md items 2 and 3),reviewPosted(withunit: whether a review by the bot's own GitHub identity, pinned to that head and starting with the verdict's token, stands on the unit's pull request — absent, never guessed, when GitHub or the identity is silent),dispositions(a coding run's, as it submitted them) andhandoff: truewhen one was submitted. The read is admitted when the run names the requested instance, or when it names an earlier attempt of the same plan and the requested instance is the latest attempt with the same repository and unit branch; the latter resolves a re-issued unit's superseded instance identity to its current runner without widening access to another plan. Every other mismatch, a run of none and an unknown id are404 not_foundalike, byte-identical. The driver keeps that opaque wire: after its ownrun-finished-<runId>event, and only then,not_foundmeans the record write may still be racing the event, so the read is repeated under a short bound with durable sleeps; after a timeout it is immediately fatal, and after the bound an event-confirmed absence is fatal too. Thus a caller learns no run-existence fact from the response, while the Workflow's own addressed event and same-plan attempt lineage distinguish pending persistence, permanent absence and a moved instance.reviewPostedis answered from the child's own record of its post first (agent-review.md item 18;reviewPost, run-history.md item 2):truewhen the record says this verdict was posted at the reviewed head to the unit's pull request,falsewithreviewPostReasonwhen the record says the post was skipped or failed and why, and GitHub is asked only when the record is silent — an older child, or a post recorded at another head, verdict or pull request — and then looked at up toREVIEW_POSTED_CHECKS(3) timesREVIEW_POSTED_RECHECK_MS(2 s) apart until it answerstrue, because the finish event wakes the runner within a second of the post and GitHub's review list can lag it; the merge step re-verifies the approval at the head regardless, so this pre-check may be patient while that guard stays strict.pr-check{ parentInstanceId, unit? }runsfindOpenPrByHeadfor the record's repository and branch (the unit's, withunit) and answers{ state: "open", prNumber, url, headSha?, autoMergeEnabled? }— and, when the body carrieschecks: true(the ending's facts read, record 0055), the check runs at that head as the merge door reads them (checks: { total, pending, failed }; GitHub unreadable leaves the field out, never fails the read); with no open pull request it runsfindMergedPrByHead—state=closed+head=owner:branch, the rows GitHub stampedmerged_at(a closed-unmerged one still reports a testmerge_commit_sha, so the time is the fact read), the latest merge first — and answers{ state: "merged", prNumber, url, sha, mergedAt },shathe merge commit on the base, so a unit whose pull request a person, or an earlier attempt of the plan, merged before the runner reached it endsmergedrather thanaborted(agent-ship.md item 15: the pre-check before the branch, and the check after a round); the pull request found either way is remembered on the unit's row; nothing of either kind is{ state: "none" }; GitHub unreachable on either lookup is502 github_unavailable. What every step reads is the oneRunsServiceevery surface reads and the App's GitHub identity — the same code paths a person'sruns getand a coding run's PR post take. The plan runner's own steps — every answer carriesat, the bot's clock, the only time the machine reads:plan{ parentInstanceId }answers the instance'splanId?,repo,base,capsand its unit rows (the children's asks and floors the machine reads fromsrc/core/budgets.ts, never from the answer) (run-history.md items 49 and 50);unit-start{ parentInstanceId, unit }opens a plan unit's thread through the requesting thread's channel (ChannelIO.openThread, the lead naming the unit, its title, the requester, the run it comes from and the branch —503 no_channelwithout a channel that can,502 thread_failedon a failed open with the row unchanged), finds the open board issue titled by the unit id, opens no review thread (agent-ship.md item 5), writes the row (threadKey,sourceUrl,issue,startedAt) and answers{ threadKey, branch, base, issue? }— idempotent, a started unit answers its thread again; the thread choice keys on the unit count (agent-ship.md item 16): a plan whose selection is exactly one unit — a task's generated plan or a one-unit checked-in selection alike — opens no thread and runs its unit in the requesting thread, only a plan of two or more units opening a thread per unit;branch{ parentInstanceId, unit? }creates the unit's branch from the base on origin and answers{ ok: true, branch, base }or{ ok: false, reason }, never a throw;round{ parentInstanceId, unit, index, agent, outcome }appends the boundary (theship_roundvocabulary) to the row and redraws the card from the instance's card handle with one line per unit;unit-end{ parentInstanceId, unit, ending: { kind, report, threadReport? }, pr? }writes the ending (the fullreport) and the pull request on the row, posts the thread's copy in the unit's thread —threadReport, the report rendered at the request's verbosity (routing-and-config.md item 28); the fullreportwhen an older driver sends none; nothing when it is empty, a quiet segment boundary, still answeringtold: truesince the thread was owed nothing — and redraws the card;finish{ parentInstanceId, outcome: completed | failed }writes the parent's run record from the rows under the instance's run id (run_meta, oneship_roundper boundary, the plan's summary as the answer), closes the card and tells the requesting thread the summary. An unknown instance or unit is404; a malformed boundary, ending or outcome is400. A unit's ending is its handoff to the board (agent-ship.md item 14's destination): when the row names a board issue,unit-endleaves the report there too under a line naming the unit, the ending and the pull request — a merge GitHub refused, a cap, a stop — best effort like the thread's.merge{ parentInstanceId, unit, prNumber, headSha }— record 0031's merge grant as a step: the runner's squash of a unit's pull request, executed only when every guard holds and refused by reason otherwise so a person decides, never a throw. In order: the bearer's actor must holdplan:merge(authorization.md item 2; decided here beside the door'scoordinator:step— withdrawn, every merge is a person's, named as the refusal); the unit's branch must be a plan branch (plan/<plan-id>/<unit-slug>) of THIS instance's plan — a task string's ship branch, another shape or another plan's branch "waits for a person's merge"; the pull request, read from GitHub (fetchPullRequestFacts), must not be the release pull request (release-please's head branch or achore(main): releasetitle — refused by name, always a person's merge), must be open, head the unit's branch and stand at exactly the approved head (headShais the full 40-character approved target, so a moved head is projected as a fresh recheck); the bot's own approving review must be pinned to that head (fetchPullRequestReviewsby the App's identity, the body starting with the LGTM token — the review agent's verdict is the guard's carrier: the verification guard'sremovedclass is a finding at minor, so an approval stands only where it passed); every check at the head must be green (fetchCommitChecks: a run that ended in anything but success, skipped or neutral is a refusal naming the runs; runs still queued or in progress, or none reported yet, answerpendingfor the machine's event-driven bounded wait (item 12)); then the squash —mergePullRequest,PUT …/pulls/{n}/mergewithmerge_method: squash,shathe approved head,<title> (#n)as the commit's title and an empty body, the shape the repository's own squash setting gives a person's merge — answers{ outcome: "merged", sha }, and GitHub's refusal (405 a conflict or a branch protection, 409 a head that moved between the check and the merge, 422) answersrefusedin GitHub's words. Every answer is200 { ok: true, outcome: merged | pending | refused, … }; GitHub unreachable — the facts, the reviews, the checks or the merge call — is502 github_unavailable, a passing condition; a malformed body is400, an unknown instance or unit404. No merge endpoint is reachable from any other code path: a task string's ship branch still never merges. The Workflow's body (src/core/coordinator/driver.ts:runPlan(step, bot, instanceId)over a structural step runner and a bot client, node-free and proven in plain Node; the shim'scoordinator.tsbinds the platform's step and the container — every bot call isPOST /admin/coordinator/<step>through the container binding, its only fetch, presented with thecoordinatorbearer from the token map, the container instance and internal origin shared with the entry throughdeploy/cloudflare/shared.ts). The first step reads the plan back, and the selection is read again at every unit boundary under its own step name (plan/<n>, the boundary's own durable read) — a unit appended to the rows mid-walk is walked after the current one, and a unit gone from the rows was merged by hand: it is walked as merged with nothing run and nothing told, a dependency on it counted done like any dependency outside the selection — so a later bot can append units to a live instance without killing it; units run one at a time in the plan's order once their in-play dependencies are done (agent-ship.md item 15's cursor), each asunit-start, then the machine's own steps under the machine's own names (<unit>/pr-check— the pre-check whosemergedanswer ends the unit with no branch and no child —<unit>/branch,<unit>/<round>/<kind>,…/wait/<n>,…/read/<n>,…/pr-check,…/busy/<n>) so a replay meets the same step, every round boundary told asround(<unit>/note/<n>) and the ending asunit-end(<unit>/end) inrenderUnitReport's words — the full report and the thread's copy at the instance's verbosity — thenfinish. Everydoruns under one retry policy — twelve retries, two minutes apart, constant, so a bot deploy and its rollover fit inside — the spawn alone with a fifteen-minute timeout (an attach can take minutes); a call that throws is the platform's to retry and the driver never catches it mid-step; a throw that escapes any unit step — including a stopped or blocked ending, a stored answer the runner cannot read, retries exhausted, or the platform's own refusal — is told once as the unit's ending (kindfailed, causestep_threw, the step, the round and the throw's one line, posted best effort under<unit>/end/threw, issue 2100) before it is rethrown, so the Workflow still fails but the unit's row never reads as the bare "no ending was recorded" seal. Every wait iswaitForEventtypedrun-finished-<runId>(run-history.md item 47; the platform's alphabet — letters, digits,-,_— or the engine refuses the send and the parent never hears the child end) for one chunk of five minutes (WAIT_CHUNK_MS), the machine walking the child's clipped budget plus five minutes in chunks with aread-recordbetween them — so an event the engine never delivered costs a chunk, not the budget — and every wait, event or timeout alike, is confirmed byread-record. A step stores the bot's reply as the wire carried it (status and text); a bot answer is a JSON object stampedat, at any status (a gate's403and abusy409are answers), and anything else — the door's refusal, the shim's own error page, a container mid-restart — is not: the client throws it for the retry, a401/403the bot did not stamp as final since no retry changes a token map; the answers the bot itself calls passing (github_unavailable,no_channel,thread_failed,unit_not_started,not_host— a409from a bot generation that no longer hosts the parent run, record 0060 — andqueued, a409saying the plane holds the child's admission until an event frees it, record 0064) are a retry, and an answer the runner cannot read — amergedpr-check without its merge commit or time among them — fails the instance at once rather than twelve times over. The branch decides who merges: a plan branch runs undermerge: runner— an approve is followed by themergestep at the approved head under<unit>/merge/<n>,pendingwaits on the intake'schecks-settled-<head>event (item 12) under the bounded fallback and asks again,mergedends the unitmergedand frees its dependents, whose coding children are briefed with the rebase onto the base that now carries it — and a task string's ship branch runs undermerge: person, endingmerge_readywithout a merge asked. A unit that ended any other way blocks its dependents: each isblocked, told so as its ownunit-endwithout a thread — naming the unit it waits on and how it ended (a refused merge, a cap, a person's merge-ready), or that the unit it waits on is blocked itself, whatever order the plan lists them in — and the plan finishesfailed,completedonly when every unit ended merged or merge-ready; a walk that threw past the retries asksfinishasfailed, best effort, and rethrows so the instance's status says what happened. What hands a request to the runner is agent-ship.md item 16. The unit nudge and the event list (record 0051, the nudge; thread-admission.md item 9): a plain reply into a thread an unfinished unit owns is appended to the unit's event list on the state Worker (coordinator_unit_events, three routes in therun_inboxshape —/runs/coordinator/events/appendassigning the sequence under the durable cap,/listfiltering unconsumed,/mark-consumedidempotent) and the instance is sent a payload-free Workflow event through the shim's relay (shimWorkflowSender), typedunitNudgeEventType({instanceId, unit})— the relay's alphabet, at most 100 characters, the unit kept whole and the instance id clipped when the two overflow (the send addresses the instance by id, so the type only names the unit within it), never a colon. The indexed idle wake is the same door'sunit-wake { parentInstanceId, unit, waitId }: it returns the answer already stored under that wait first; otherwise it reads every unconsumed event, recognizes requester and stopper participation anywhere in that folded batch, re-resolves the requester's scoped grant, applies the stopped-lease or renewal rule, and stores the typed answer (segment,answered,stopped,expired), event marks and any newly spent renewal's segment row in one durable transaction (reopening segment one writes no segment row). The driver waits under<prefix>/idle/<n>, calls the wake under<prefix>/idle/<n>/wake, and writes an expired or stopped ending under<prefix>/idle/<n>/end; a thrown wake advances to the next index, while the walk starts no later unit. A stopped segment resumes with its original segment number and remaining lease under a fresh durable attempt prefix, so none of the cut attempt's cached Workflow steps replay.The model proxy's paths ride the same forwarding as
/ingress(model-proxy.md; record 0032).POST /v1/messagesandPOST /v1/chat/completions— the per-run model-credential proxy — reach the container the way/ingressdoes: the shim answers neither itself and forwards them unread under the one route wordmodel-proxy, and the Access application does not cover them, so the run bearer they carry is the whole door, decided in the bot. The shim holds the provider keys only to hand them into the container's environment; nothing about the proxy lives in the Worker.POST /admin/model-proxy/beareris one more/admin/*path behind the ingress token map — an operator's probe bearer for a live run, for thedeploy:writegrant the restart and the crash use.A token bound to a person sends the person's request (authorization.md item 15). An entry may carry
emailbesidesubjectandchannel;handleIngressRequestresolves it through the injectedpersonByEmail(the bot wires the Slack adapter's cachedresolvePersonByEmail) and theIncomingMessagenames the person asuserIdanduserNameand the credential asauthenticatedAs: http:<subject>; the channel and thread namespacing, the fail-closed gates and thedispatchgrant check (requireDispatch, onhttp:<subject>) are unchanged. No email, no lookup wired, no match or a failing lookup: the message is the credential's, exactly as items 1 and 3 describe; an entry withoutemailnever asks.The GitHub intake — settled checks and human answers wake their owning unit (
src/core/coordinator/checksIntake.ts, the pure handlers;src/channels/githubWebhook.ts, its node:http adapter atPOST /webhooks/github, wired into the samePORTserver as/ingress; agent-ship.md item 9). Acheck_runwebhook delivery is read only after itsX-Hub-Signature-256HMAC-SHA256 over the raw body verifies against the configured webhook secret, compared without short-circuiting; a missing or wrong signature is401, and no secret disables the intake (503), never open. A delivery whose action iscompletednames a head: when every check run at that head has settled, the intake sends the typedchecks-settled-<head>event (checksSettledEventType,sendChecksSettled— the head sha is hex, inside the Workflow event alphabet) to each coordinator instance whose merge step waits at that head; any other event or action is acknowledged and ignored, a malformed body is400, and a send the engine refuses is answered, never thrown — the driver's bounded merge wait is the fallback, and the merge door stays the guard: the woken machine re-asks it before anything merges. Anissue_commentcreateddelivery on a pull request is also verified through that door: a sender whose GitHub type isUserand whose resolved account exactly matches the requester's stored GitHub binding is appended once (comment id as the event id) to the unfinished runner-owned unit's durable event list, attributed by GitHub login, aswakewhile idle orsteerotherwise, and sends the unit nudge; unbound or differently bound accounts, bot comments and unowned or ended pull requests are acknowledged and ignored. The secret isGITHUB_WEBHOOK_SECRETin the bot's environment. The intake requires an available container: the shim's start (deploy/cloudflare/worker.ts,SwitchboardServer.fetch) retries the platform's no-instance condition, runtime-signalled rollout and verified loss during port waiting once after one second. Startup has not consumed or forwarded the request. A successful retry forwards that original request once; a second unavailable start answers503andRetry-After: 1without exposing the error. The retry never wraps request forwarding, so a downstream response or failure cannot replay model calls, tool effects or webhook delivery. Fixed log outcomes identifyretrying,recovered,unavailableand an unclassified second-attemptfailedstartup. They contain no request fields or error text. Port loss is verified only in the SDK's port-wait phase, after instance acquisition succeeds, by a failed probe with the container stopped; the SDK rethrows the original TCP error, not its hook-only crash diagnostic. A stopped container does not distinguish a rollout from an app crash, so both are unavailable in that phase. Port errors while still running, unrelated failures outside port waiting and downstream errors remain unchanged. This conveys temporary unavailability; it does not accept or preserve the event. GitHub does not automatically redeliver failed webhook deliveries; failed deliveries require explicit redelivery. The merge wait's fallback below re-reads check status, but no automatic webhook redelivery or missed-comment reconciliation is implemented. Who waits where is the merge-wait registry (createMergeWaitRegistry): the merge step's everypendinganswer notes its instance at the asked head (noteMergeWait,src/channels/adminCoordinator.ts), entries expiring after the whole merge wait's span — in-memory on purpose, because a restart's loss only costs the fast path: the driver'sMERGE_WAIT_CHUNK_MSfallback re-asks the door on the old poll's cadence regardless. How the send crosses: the bot holds no Workflow binding, sosendChecksSettledridesshimWorkflowSender(src/core/coordinator/instancesClient.ts) to the shim's event relayPOST /admin/coordinator/instances/<id>/events(parseInstanceEventPath,parseSendEventRequest,sendEventResponse/readSendEventAnswerinsrc/core/coordinator/instancesRoute.ts; the Worker'shandleCoordinatorInstanceEvent) — the same door as the create and the status (thecoordinatorbearer, the bot'sauthorizeanswer), delivering through the Worker's ownSHIP_COORDINATOR.get(id).sendEvent; the engine'sinstance.not_foundis404, any other refusal502by reason, both read back as a failed send.
Validation criteria
| Criterion | Evidence |
|---|---|
| A composed request thread key exceeding the hosted-run ledger limit is refused before dispatch | [unit] src/channels/http.test.ts::handleIngressRequest (transport gating + dispatch)::rejects channel and thread pairs whose hosted run key exceeds the ledger limit |
| Item 9: the Workflow's idle body waits under indexed names, replays durable wake answers, opens or resumes the answered lease under a fresh durable attempt prefix, advances after an answered or failed wake, expires on timeout or the wake bound, and parks later units | [unit] src/core/coordinator/driver.test.ts::the plan runner's driver — the Workflow body over the step runner (item 9)::* |
Item 9: unit-wake covers stored replay, no events, requester and stopper authority across the whole folded batch, other-sender decisions, re-resolved grants, stopped remainders without a segment-one renewal row, cap and fit refusals, and the wake limit | [unit] src/channels/adminCoordinator.test.ts::the plan runner's steps — plan, unit-start, branch, round, unit-end, finish (item 9)::unit-wake*; deploy/cloudflare-memory/runLedger.test.ts::run ledger — the coordinator's unit rows (item 50)::the validating wake boundary accepts a first-segment resume… |
| Item 12: no instance, a runtime-signalled rollout or a failed SDK port wait with the acquired container stopped retries startup once after one second, before consuming or forwarding the original request; a second unavailable start answers the same private 503 with a retry hint; ambiguous TCP/hook messages alone do not prove port loss; errors while running or outside port waiting still throw; downstream responses and failures are never retried; fixed retrying/recovered/unavailable/failed log outcomes retain no request fields or error text | [unit] deploy/cloudflare/containerStartContract.test.ts::SwitchboardServer.fetch — container availability::*, ::SwitchboardServer.fetch — SDK port wait::*; deploy/cloudflare/containerStart.test.ts::container start — a roll that takes the container away is retryable, not a 500::* |
Item 11: a bound token's request is the person's (userId, userName) with authenticatedAs the credential, the lookup asked once with the entry's email, the dispatch gate still on the credential; an unfound person or no lookup sends as the credential; an unbound token never asks | [unit] ::handleIngressRequest (transport gating + dispatch)::a token bound to a person by email → the message is the person's (userId, userName) and names the credential as authenticatedAs; the dispatch gate still asks about the credential, ::a bound token whose person cannot be found (no lookup, no match, a failing lookup) sends as the credential, exactly as an unbound one |
| Valid token maps to its identity; wrong/unknown/missing/malformed token → null; all tokens checked (position-independent); array header handled | [unit] src/channels/http.test.ts::authenticate (bearer auth, constant-time)::* |
Valid request → dispatch() called with the correctly namespaced IncomingMessage; collected reply returned as { reply } (200) | [unit] ::handleIngressRequest (transport gating + dispatch)::valid token → dispatch called with the namespaced IncomingMessage; reply returned |
Namespacing: channel/thread defaults; a token-pinned channel overrides the body only after the body component and the pin independently pass the component rule (string, non-empty, at most 128 characters, no : or #); invalid components are 400 before dispatch, distinct pairs cannot collapse to one key, and a thread cannot forge #host | [unit] src/channels/http.test.ts::handleIngressRequest (transport gating + dispatch)::defaults channel/thread when the body omits them, ::a token-pinned channel overrides the body's channel, ::rejects bad channel/thread types, empty values, delimiters, and overlong values at the HTTP boundary, ::rejects ambiguous pairs from distinct pinned credentials and cannot forge a #host thread, ::rejects empty and overlong identity-pinned channels before composing a thread key |
| Fail-closed: no tokens configured → 503 disabled, dispatch never called | [unit] ::no tokens configured → 503 disabled, dispatch never called (fail-closed) |
| Missing/invalid token → 401, dispatch never called | [unit] ::missing/invalid token → 401, dispatch never called |
A token without a dispatch grant → 403 {error:"forbidden", code:"unauthorized"}, dispatch never called, decided before the body is read; a configured dispatch grant permits it | [unit] ::handleIngressRequest — the dispatch scope (fail-closed)::* |
| Invalid JSON / missing-blank text / bad history → 400; non-POST → 405 | [unit] ::invalid JSON → 400, dispatch never called, ::missing/blank text → 400, ::malformed history → 400; well-formed history reaches io.history(), ::non-POST → 405 |
HttpIO: reply collection, no-op status, history replay/default and stable coordinator job thread keys | [unit] ::HttpIO (single-shot ChannelIO)::* |
Run receipt: response carries run: {id, status} when the core finished a run (truthful failed/stopped statuses), never a token; absent when no run was created; HttpIO.run() undefined until runFinished | [unit] ::run receipt in the response …::* |
The token map has ONE parser shared with the Worker shim: parseIngressTokens delegates to src/core/ingressTokens.ts (parseIngressTokenMap, tokenForSubject — ambiguous subject → undefined) | [unit] src/core/ingressTokens.test.ts::*, src/channels/http.test.ts::parseIngressTokens (env → config, fail-closed)::* |
| Body size cap: reads under cap, rejects over cap; wrapper answers 413 and destroys the request | [unit] ::readBody (size cap)::*, ::createIngressHandler (node:http wrapper)::answers 413 and destroys the request when the body exceeds the cap |
| node:http wrapper reads body, dispatches, writes 200 JSON | [unit] ::createIngressHandler (node:http wrapper)::reads the body, dispatches, and writes a 200 JSON reply |
| Pre-auth: an unauthorized request is rejected from headers without reading the body | [unit] ::createIngressHandler (node:http wrapper)::rejects an unauthorized request without reading the body (pre-auth); ::authorizeRequest (header-only gate)::* |
| Env→config parsing is fail-closed: valid map parsed; unset/blank/malformed → disabled; malformed entries skipped without opening | [unit] src/channels/http.test.ts::parseIngressTokens (env → config, fail-closed)::* |
Async mode: "async": true → 202 { runId, runUrl, threadKey } before the run finishes; runUrl honors PUBLIC_BASE_URL (path-only fallback); channel pin reflected in threadKey; no-run request falls back to sync 200 | [unit] ::async mode ("async": true → 202 Accepted, run continues in background)::answers 202 with runId, runUrl (PUBLIC_BASE_URL) and threadKey before the run finishes, ::async mode ("async": true → 202 Accepted, run continues in background)::runUrl degrades to a path when no publicBaseUrl is configured, ::async mode ("async": true → 202 Accepted, run continues in background)::the token's channel pin applies on the async path (threadKey reflects the pinned channel), ::async mode ("async": true → 202 Accepted, run continues in background)::an async request the core answers WITHOUT a run (no runStarted) falls back to the sync 200 shape |
Async path authorization unchanged: unknown token → 401, dispatch-less token → 403, non-boolean async → 400 — dispatch never called | [unit] ::async mode ("async": true → 202 Accepted, run continues in background)::authorization applies unchanged on the async path: unknown token → 401, dispatch never called, ::async mode ("async": true → 202 Accepted, run continues in background)::a dispatch-less token is refused on the async path too (403, fail-closed), ::async mode ("async": true→ 202 Accepted, run continues in background)::a non-booleanasync → 400, dispatch never called |
Sync path byte-identical without async (and with "async": false) | [unit] ::async mode ("async": true→ 202 Accepted, run continues in background)::the sync path is unchanged: same body withoutasync → 200 with the reply, ::async mode ("async": true → 202 Accepted, run continues in background)::async: false behaves exactly like omitting it |
Drain: the async run counts in flight after the 202 and completes in the background with its runFinished receipt | [unit] ::async mode ("async": true → 202 Accepted, run continues in background)::drain semantics: the run counts in flight after the 202 and completes in the background with its receipt |
Live: an authed POST /ingress reaches an agent end-to-end; unauthenticated is refused | [agent] Requires the bot deployed with SWITCHBOARD_INGRESS_TOKENS set and PORT exposed: POST /ingress with a configured bearer and {"text":"…"} → 200 { reply }; the same request without the header → 401. |
Item 1: the dispatched message carries receivedAt | [unit] ::handleIngressRequest (transport gating + dispatch)::valid token → dispatch called with the namespaced IncomingMessage; reply returned |
| A coordinator child whose initial native claim outcome is unknown returns unavailable and leaves its original spawn effect active and uncertain. Both possible server commit outcomes preserve the original, with no settled refusal or repeated dispatch. | [unit] src/core/dispatcher.test.ts::run ledger write-through (docs/reference/specs/run-history.md item 35)::coordinator producer identity::keeps an unknown initial claim uncertain at the actual spawn endpoint* |
9: the door — a bearer without coordinator:step is 403 on every route naming the grant, no bearer and an unknown bearer 401, no token map 503, nothing dispatched or read; the granted bearer's authorize answers 200 with the subject; a non-POST is 405, an unknown step 404 | [unit] src/channels/adminCoordinator.test.ts::the coordinator routes — the bearer (item 9)::* |
| 9: the node adapter answers an unknown step, a non-POST and a refused bearer (404 / 405 / 401 / 403) from the headers alone — the body never read, the request destroyed — and an admitted POST reads the body once the door is open, looking at the bearer exactly once | [unit] src/channels/adminCoordinator.test.ts::createAdminCoordinatorHandler — the node adapter decides the door from the headers alone (item 9)::* |
9: the row that admits it — coordinator:step on command for a service actor holding the grant; an admin's all, a token without it and the schedule actor are denied | [unit] src/core/authz/policy.test.ts::POLICY coverage::*::allows its allow cases through this row and through the table, ::*::denies its deny cases in this row and across the table |
9: spawn dispatches the child as the parent record's user, channel and thread with the preset directive, repository, budget and prompt as its text and the coordinator tag as its option, a body naming another user ignored; answers the run id and thread at registration; an unknown instance is 404 and nothing is dispatched; the tag carries the instance's base — the branch the child's pull request targets — and no base field for an instance without one | [unit] src/channels/adminCoordinator.test.ts::POST /admin/coordinator/spawn — the child as the parent record's requester (item 9)::dispatches the child as the instance's user, channel and thread…, ::an unknown parentInstanceId is refused 404 and nothing is dispatched, ::the tag carries the instance's base — the branch the child's pull request targets — and no base field at all for an instance that knows none… |
9: retry-safe — a live run on the thread carrying the key answers alreadySpawned; a live run without it (here or on another generation's row) answers busy naming it; a finished run carrying the key answers alreadySpawned; a coordinator_thread_live refusal is answered from the run that took the thread; none of these dispatches | [unit] ::POST /admin/coordinator/spawn — the child as the parent record's requester (item 9)::a live run on the instance's thread carrying the same key answers its id with alreadySpawned…, ::a live run on the thread without the key — another step's child, or a person's run — answers busy…, ::a finished run in the instance's thread carrying the key answers its id with alreadySpawned…, ::a spawn the admission stage refused because a run took the thread meanwhile (coordinator_thread_live)… |
9: a requester without the preset's grant is refused with the authorize stage's own name and what the thread was told; a dispatch that ended with no run and no gate's name is spawn_failed; a channel no thread can be rebuilt on is no_channel; a malformed body is 400 before anything is read | [unit] ::POST /admin/coordinator/spawn — the child as the parent record's requester (item 9)::a requester without the preset's grant is refused by the authorize stage's own name…, ::a dispatch that ended with no run and no gate's name is a failed spawn (502)…, ::validates the body before anything is read… |
9: read-record answers a live child's and a finished child's facts; a run on an earlier attempt of the same plan resolves to the later current attempt; a genuinely foreign run, a run of none and an unknown id stay byte-identical not_found; after its own finish event the real runner retries a record racing its write under the short visibility bound and resumes when the real store receives it, while a timeout-confirmed absence is immediately fatal | [unit] src/channels/adminCoordinator.test.ts::POST /admin/coordinator/read-record — a run of the instance, and no other (item 9)::*; ::read-record recovery — the real runner step over the real store (item 9)::* |
9: read-record answers a finished child's verdict and reviewed head, dispositions and handoff while the registry still holds its row — the record landed inside the registry's window, and the store is what the runner reads | [unit] ::the plan runner's steps — plan, unit-start, branch, round, unit-end, finish (item 9)::read-record answers a finished child's verdict and reviewed head, dispositions and handoff while the registry still holds the row… |
9: a finished child with an unresolved Git-door write cannot be settled or used to recover its original unit; both routes refuse with door_publication_unresolved until the write has authoritative reconciliation | [unit] src/channels/adminCoordinator.test.ts::POST /admin/coordinator/read-record — a run of the instance, and no other (item 9)::refuses to settle a finished child while its Git-door write still has an unknown outcome, ::POST /admin/coordinator/recover-unit — unchanged-head original-unit recovery::refuses an original-unit recovery while a same-unit Git-door outcome remains unknown |
9: a registry-finished child is not a completed coordinator step until its sealed final record is available; read-record retries a missing record and fails closed when the record store is unavailable, then honors any unresolved Git write in the final record | [unit] src/channels/adminCoordinator.test.ts::POST /admin/coordinator/read-record — a run of the instance, and no other (item 9)::waits for the durable final record when the registry finished before its reply was sealed |
9: the runner retries an addressed record_pending after either a finish event or a timeout-confirmed read under the short visibility bound, then continues from the sealed final record; opaque not_found remains retryable only after its addressed finish event | [unit] src/core/coordinator/driver.test.ts::the plan runner's driver — the Workflow body over the step runner (item 9)::the event confirmation retries an addressed finished child's pending record under the short bound and continues, ::the timeout confirmation retries an addressed finished child's pending record under the short bound and continues |
9: pr-check answers none, or the number, url and head of the open pull request heading the record's branch (with checks: true, the check runs at that head too, left out when GitHub cannot be read), or — with no open one — merged with the number, url, merge commit and when of the merged pull request heading it, asked only after the open lookup came back empty and never when an open one exists; an unknown instance 404; GitHub unreachable on either lookup 502 | [unit] ::POST /admin/coordinator/pr-check — the open pull request heading the instance's branch (item 9)::* |
9: findMergedPrByHead asks for the closed pull requests heading the branch, newest first, answers the latest merged one with its number, url, merge commit and when, skips a closed-unmerged row (a test merge commit is not a merge) and a merged row without a well-formed merge commit, null with none, throws on a failed lookup and before any fetch without a credential | [unit] src/execution/githubPulls.test.ts::githubPulls::findMergedPrByHead asks for the closed pull requests heading the branch…, ::throws when no credential is available, before any fetch |
9: the driver on a unit already merged — the pre-check under <unit>/pr-check answering merged ends the unit with no branch and no child, the ending told with the pull request as already merged and when, its dependent starting and the plan completing; a merge landing during round 0 ends the unit merged at the round's pr-check with round 0 noted completed and no review child; a merged answer without its merge commit is unreadable and fails the instance at once | [unit] src/core/coordinator/driver.test.ts::the plan runner's driver — a unit whose pull request already merged (item 9)::a unit merged before the attempt…, ::a merge that lands during round 0… |
9: the shim's instance route — the body it accepts, the bot's authorization answer read fail-closed, the wire shape of created / duplicate / failed, typed pre-create denial versus attempted-unresolved, and its read back bound to the requested id; the status route's path and typed readback also bind to that id, with absence only on the engine's instance.not_found code — a failure whose text merely says not found is a failure | [unit] src/core/coordinator/instancesRoute.test.ts::* |
9: the Workflow's boundary — the binding's class is declared in coordinator.ts over the narrowed env and re-exported by worker.ts, same-script under the shim's own name; the module imports the entry type-only, names no forwarded secret, fetches only through the container binding with the coordinator bearer from the token map, and its run() selects runOriginalUnitRecovery only from typed recovery parameters and otherwise runs runPlan; the state Worker's template binds the same class across scripts by the bot's script name | [unit] deploy/cloudflare/coordinator.test.ts::*; src/core/coordinator/instancesClient.test.ts::createInstanceViaShim — the bot's request for a coordinator instance::forwards typed Workflow parameters unchanged |
9: recover-unit claims one ended original row from its durable instance/unit, exact segment-and-attempt review record, publication binding and fresh verified GitHub head; the Workflow and each actual child admission revalidate the same claim, owner, fixed head and absolute lease deadline. Definite create failure or a moved head during Workflow revalidation restores the exact prior row and releases only the matching owner; an unanswered create stays claimed so retry meets the same Workflow id; a terminal duplicate records the evidence consumed and restores the ending. Only an attributable completed findings child receives push credit; a uniquely proven failed child's salvaged head may CAS-advance the review target while lastPush stays unchanged and the missing findings outputs remain required. An ended child with a verified private H0-based patch may restart findings at a moved H1 without push credit, even after earlier same-ref pushes to other heads; a prior push to H1 refuses. When the live check already adopted uncredited H1, recovery matches the posted H0 review and original child tag against the same PR owner/ref, then accepts only the verified H1 target with the saved patch; a missing patch or third target refuses. Requester/thread mismatch, evidence ambiguity, stopped-segment or cost-cap ambiguity, exhausted budgets, stale CAS, ownership loss and rollback/cleanup loss refuse before child admission; terminal settlement is acknowledged before completion | [unit] src/channels/adminCoordinator.test.ts::POST /admin/coordinator/recover-unit — unchanged-head original-unit recovery::*; src/core/coordinator/driver.test.ts::runOriginalUnitRecovery::* |
9: pr-check follows the original adopted PR through a moved-head findings or review child only after that child drains, the current PR/ref/base/full head is verified twice, and sole ownership and its original step identity agree. An unchanged binding is replayed without adoption; a completed findings child's own head goes through publication proof, while a foreign head advances only the review target for the state machine's findings gate, leaving lastPush unchanged and granting no review by itself. Changed owners, moving refs and incomplete proof refuse without authorizing a new writer. | [unit] src/channels/adminCoordinator.test.ts::POST /admin/coordinator/recover-unit — unchanged-head original-unit recovery::a superseded check accepts a returned bound head without adopting or changing its push, ::a superseded check cannot credit a child's head after the ref moves during findings proof, ::a drained findings child hands a foreign moved head to the findings gate without claiming its push |
9: the plan runner's own steps — plan answers the units, caps, base, repository and budgets; unit-start opens an ordinary multi-unit plan's unit thread through the requesting thread's channel and no review thread, finds the board issue, writes the row, and answers the same thread twice; it runs an ordinary one-unit plan's unit in the requesting thread with no thread opened — the choice keyed on the unit count (agent-ship.md item 16), generated and checked-in plans alike; a main task with a durable workBrief instead uses its stable private worker thread and opens no Slack child thread; refuses without a channel or on a failed open; branch creates from the base and answers a failure as ok: false; spawn composes a contract brief and a findings brief into the child's turn and options, dispatches a review brief into the unit's thread, refuses a brief before the unit started and one it cannot compose, and refuses a malformed body; read-record answers the pull request, the verdict, the reviewed head, whether the verdict stands — from the child's recorded post without asking GitHub (true at the reviewed head on the unit's pull request; false with the recorded reason for a skip), a recorded post at another head, verdict or pull request left to GitHub, and GitHub asked up to three times a pause apart when the record is silent (false for another author, another head or another verdict after the last look; absent when GitHub stays silent) — the dispositions and the handoff; pr-check remembers the pull request on the row, a merged one too; round appends the boundary and redraws the card; unit-end writes the ending and posts the thread's copy of the report (the full report from an older driver, nothing for an empty copy while still answering told); finish writes the parent's record from the rows, closes the card and tells the requesting thread | [unit] src/channels/adminCoordinator.test.ts::the plan runner's steps — plan, unit-start, branch, round, unit-end, finish (item 9)::* |
| 9: the briefs composed bot-side — the contract from the plan, the specs and the rules at the base ref with the board issue, a task unit's from its authenticated durable unit checkpoint; the review turn with the prior rounds' records on a re-review, the dispositions matched and the dropped ids noted; the findings message from the review run's findings and final words; an unreadable plan, a missing run or a brief of an unknown kind throws by name | [unit] src/core/coordinator/briefs.test.ts::contractFor — the unit's contract from the repository at the base ref::*, ::composeChild — the child a brief names::* |
9: merge — every guard green squashes at exactly the approved head with the title as the commit and answers the squash's sha; a coordinator bearer without plan:merge is refused naming the grant and GitHub is never asked; a task instance, a branch of another shape or another plan waits for a person and the release pull request is refused by name; a closed pull request, one heading another branch, a head that moved, a review by another author or another verdict are each refused naming what is off; red checks refuse naming the runs, running or unreported checks answer pending; GitHub's refusal of the squash is answered in its words; the facts, the reviews, the checks or the merge call unreadable are a passing 502; a malformed body is 400, an unknown instance or unit 404; unit-end leaves the ending on the board issue as the runner's handoff and a failed comment never fails the step | [unit] src/channels/adminCoordinator.test.ts::POST /admin/coordinator/merge — the runner's squash of a unit's pull request (item 9)::* |
9: the merge's GitHub — the facts carry the title; mergePullRequest squashes at exactly the head with <title> (#n) and an empty body, requires native merged: true with a full merge SHA, answers GitHub's refusal with its status and words and leaves failed or malformed calls unknown; fetchCommitChecks reads every exposed page with stable native count, unique check IDs and matching head; incomplete, contradictory or malformed data is unavailable; success, skipped and neutral are green | [unit] src/execution/githubPulls.test.ts::githubPulls::the plan runner's merge — mergePullRequest and fetchCommitChecks::* |
9: the child's text names the unit's branch — agent:<preset> [budget:<n>] in <owner/name> on branch <ref>: <prompt> | [unit] src/core/dispatch/spawn.test.ts::childRequestText / maxChildrenOf::* |
9: the Workflow's body — the steps in the machine's order under its names, every spawn typed by its brief and clipped budget, a plan branch's approve followed by the merge step at the approved head and the unit ending merged, the pending poll slept and asked again, a refused merge ending the unit with the refusal, a task string's ship branch never asking, every wait typed run-finished-<runId> for one chunk and followed by a read-record, the boundaries and the ending told, the finish; a lost event costing one chunk — a timeout confirmed by read-record and a live child waited on again for the next chunk; busy waiting on the run named or sleeping the retry; units one at a time in the plan's order, a dependent of a merge-ready or failed unit blocked with its own ending and the plan failed; a throwing call retried under the policy and never caught, the finish asked as failed past it; an unreadable plan answer failing the instance at once; a bot answer is a JSON object stamped at and the bot's passing conditions — a 409 queued spawn among them — are a retry; the selection re-read at every unit boundary under plan/<n>: a unit appended to the rows walked after the current one, a unit gone from them walked as merged with nothing run | [unit] src/core/coordinator/driver.test.ts::the plan runner's driver — the Workflow body over the step runner (item 9)::* |
9, live: the routes answer on the deployed bot with the coordinator bearer and refuse without it; an instance runs its first step | [agent] (human-gated: needs the coordinator entry in SWITCHBOARD_INGRESS_TOKENS and grants.http:coordinator: { actions: [coordinator:step] } in the deployed config.) POST https://<bot>/admin/coordinator/pr-check with the bearer and {"parentInstanceId":"ship_none"} → 404 {"ok":false,"error":"unknown_instance"}; the same request with the cron bearer → 403 naming coordinator:step; without a bearer → 401; POST https://<bot>/admin/coordinator/instances with the bearer and {"id":"ship_receipt_<n>"} → 201 {"ok":true,"id":…,"created":true} and the instance listed in the Workflows dashboard at its plan step, retrying every two minutes on the unstamped 404 unknown_instance and errored after the twelfth; a second POST with the same id → 409 duplicate_instance. |
| 9, live: a plan run survives the bot and the shim | [agent] (human-gated: a staging deployment with the coordinator bearer, its grants and run history on the state Worker.) agent:ship in <owner/repo>: plan <path>.md units U<n> in Slack → the thread's card says the plan runner took it, a unit thread opens in the channel, and the coding child runs there; the bot container is killed mid-round → the child's record closes interrupted, the runner's read-record reads it and the unit ends with the interrupted note in its thread; on a fresh request, the bot is deployed while the runner waits on a child → the wait's run finished still lands (or the timeout's read-record reads the finished record) and the review round starts; a spawn attempted during the deploy is retried under the policy and the child registers once the container is live; the approving review at the head and green checks are followed by the runner's squash of the unit's pull request (<title> (#n), an empty body) and the unit's thread says Merged … under plan:merge; the plan's summary lands in the requesting thread and runs get <run id> shows the parent record with one ship_round per boundary. |
10: the shim answers three paths itself and forwards every other path to the container, knows nothing of the proxy's paths, and holds the model keys only for the container's environment; its route table names the three paths model-proxy | [unit] deploy/cloudflare/modelProxyForwarding.test.ts::the shim forwards the model proxy's paths to the container blind::*, src/core/trace/workerTrace.test.ts::shimRoute::names the model proxy's three paths as one route word, and no other /v1 path… |
| 12: a verified pull-request comment preserves its signed human author and installation while crediting only the canonical assignee. Current organization, steer and repository permissions remain conjunctive for a delegated source. The original owner atomically compares the complete saved question and keeps its first accepted answer; an unbound, stale or differently assigned comment cannot wake the unit. Bot comments and unowned pull requests are ignored | [unit] src/core/coordinator/checksIntake.test.ts::the pull-request comment intake — a person's answer wakes the owning unit::* |
| 12, live: the merge wait wakes on the settled delivery, not the fallback timeout | [agent] A checked-in plan unit under the runner's merge grant whose review approves before its checks finish; the GitHub App's delivery for the last check run at the approved head answers 200 with settled: true and sent: 1 of 1; the unit's card reads merged within seconds of that delivery; the unit's run record shows the merge wait ended on the event, not on the fallback timeout. The receipt for this procedure is posted on the agent-ship receipts issue in the tracker. |
| Criterion | Proof |
|---|---|
| Requester comments resolve canonical durable unit ownership after restart. Incomplete ownership refuses; a new ended append is ignored. An exact previously recorded question comment can acknowledge its original sequence after the question or PR owner ends without another append or nudge. An exact recovery journal guard precedes ordinary recovery dedup and addresses the original recovery Workflow. | [unit] src/core/coordinator/checksIntake.test.ts::the pull-request comment intake — a person's answer wakes the owning unit::*; src/core/runnerOwnership.test.ts::*::* |
| Criterion | Proof |
|---|---|
| A failed Workflow nudge retains the committed requester comment and offers the same original unit for reconciliation; it cannot dispatch replacement work. | [unit] src/core/coordinator/checksIntake.test.ts::*::retains a committed comment and offers the original owner for reconciliation after a failed nudge |
Serving build receipt
An asynchronous admission or synchronous response with a finished run includes the serving process's existing build metadata when configured. A missing stamp remains absent. It names the responding process and never claims task success; deployment smoke compares its exact commit with the observed build and separately reads original run facts.
| Criterion | Proof |
|---|---|
| The finished ingress receipt carries the serving process build without a view token or invented stamp | [unit] src/channels/http.test.ts::run receipt in the response::binds the finished ingress receipt to the serving process build |
| An asynchronous admission carries the actual serving build before the run completes | [unit] src/channels/http.test.ts::binds an asynchronous admission to the serving build before completion |
Organization admission
When the installation supplies IngressOptions.admit, HTTP admits work through the durable organization service instead of waiting for a model reply. The authenticated bearer digest stays server-only and identifies the exact key, even when active keys share a subject. The adapter does not correlate an email to a person. A supplied idempotencyKey is a string of 1 to 128 characters; its digest names the native message. Without a key, each send receives a new native message ID. Exact retry keeps the same credential, thread, text and key; changed payload conflicts. Caller-supplied conversation history is refused. Current authorization still applies to retries.
A pending or accepted receipt returns 202, throttling returns 429, and rejected or stale receipts return 409. Receipt and original owner are reported when known; admission does not prove completion or delivery. Invalid admission returns 400, a missing or fenced authorization returns 403, and unavailable or busy admission returns 503. Without admit, the existing single-shot reply and async lifecycle remain available.
The result of an admitted asynchronous request is the original run's saved output, read through the caller-authorized run service. This is separate from physical channel delivery: a channel-less job can retain replyOk: false while its result is available. Release acceptance requires an accepted original receipt, matching owner, candidate and profile, a sealed persisted run with complete retained events, and the expected output bytes. A receipt or reply-key digest alone does not prove output.
| Criterion | Proof |
|---|---|
| Exact HTTP retry retains one durable receipt and original owner; changed text conflicts. | [unit] src/channels/http.test.ts::HTTP organization admission::returns the original durable receipt and owner on exact retry, and conflicts on changed text |
| Two same-subject bearer keys retain distinct exact digests; body fields and matching email cannot substitute the authenticated actor. | [unit] src/channels/http.test.ts::HTTP organization admission::keeps two same-subject keys distinct and ignores forged credential and email identity |
| Supplied history and malformed retry keys refuse before admission; current policy loss refuses the original retry. | [unit] src/channels/http.test.ts::HTTP organization admission::refuses supplied history and malformed keys before admission and keeps current policy denial honest |
| Rejected and throttled receipts, unavailable admission, and invalid admission return their actual status without claiming acceptance or completion. | [unit] src/channels/http.test.ts::HTTP organization admission::returns rejected and throttled receipts without claiming acceptance, src/channels/http.test.ts::HTTP organization admission::reports unavailable or invalid admission without claiming receipt or completion |
| The installation without organization admission retains its existing collected reply. | [unit] src/channels/http.test.ts::handleIngressRequest (transport gating + dispatch)::valid token → dispatch called with the namespaced IncomingMessage; reply returned |
Native GitHub question answers
The host injects current GitHub account binding and native requester proof. A human question cannot fall back to a message-body actor, email correlation or old source permissions. Missing current proof or incomplete retained ownership returns unavailable without appending. The server supplies this verifier before routing the signed webhook question path; missing configuration still holds intake. This preserves the existing execution engine and original unit owner.
| Criterion | Proof |
|---|---|
| Canonical question answers preserve signed installation and author provenance separately from the credited assignee; forged body identity fields and invalid signatures cannot append. | [unit] src/channels/githubWebhook.test.ts::GitHub native question answers::keeps signed installation and human comment provenance while crediting only the bound canonical assignee |
| An exact recorded comment retains its original event after question resolution, PR ending and consumption. Changed bytes conflict; incomplete replay inventory cannot select another owner. | [unit] src/core/coordinator/checksIntake.test.ts::the pull-request comment intake — a person's answer wakes the owning unit::replays the original saved question answer after its question resolves and the PR owner ends; ::holds a replay when retained inventory is incomplete instead of selecting a new owner |
| Comments before the current question, another person's assignment and a changed complete question cannot append. The first browser or GitHub answer owns that question. | [unit] src/core/coordinator/checksIntake.test.ts::the pull-request comment intake — a person's answer wakes the owning unit::rejects a comment written before the current question and a requester assigned someone else's question; ::an answer racing a changed question cannot append to its original unit; ::keeps the first browser answer when GitHub answers the same captured question |
| Missing source proof holds the comment; loss of current organization, steer, repo or exact source refuses. Delegation requires both current person and credential policy without changing either actor's identity or self set. | [unit] src/core/coordinator/checksIntake.test.ts::the pull-request comment intake — a person's answer wakes the owning unit::holds unavailable native proof and refuses current organization, steer, repository and exact source loss; ::uses both current policies for a verified delegated owner and refuses the actual source's revoked ceiling |
GitHub installation activity conversion
The same normalization entry accepts installation actions created, deleted, new_permissions_accepted, suspend and unsuspend, plus installation_repositories actions added and removed. These are installation-wide source facts. They require a configured host, App and installation, but no repository. Repository-scoped events still require the configured repository ID. The payload must name the configured installation and App.
The fact keeps native installation identity, action, permission metadata, actor ID and repository-selection state. Missing repository lists remain null; empty lists remain empty. Added and removed lists are retained separately. An all selection does not turn a supplied list into a complete access inventory. An empty removal list does not prove that no access was lost when selection changes. The receiver must still invalidate/check access in the common core before delivery; no source fact grants permission or starts work.
Native payloads remain detached, revision-hashed and bounded by the existing source codec. A native requester field remains source data and cannot become a Switchboard requester. This cut adds no receiver, source producer, access-cache mutation or delivery caller. Repository rename, transfer and visibility conversion, canonical access checks and source recovery remain pending. The contracts follow the GitHub webhook reference and Octokit webhook types.
| Criterion | Proof |
|---|---|
| Supported installation and repository-selection actions retain one installation-wide fact without repository or requester authority | [unit] src/channels/githubInstallationActivity.test.ts::GitHub installation activity normalization::retains installation-wide…; ::retains …repository lists… |
| Missing, empty and all-repository selections remain distinct; exact source snapshots and revisions do not borrow delivery identity | [unit] src/channels/githubInstallationActivity.test.ts::GitHub installation activity normalization::keeps unknown, empty and all-repository selections distinct; ::freezes input and separates delivery identity from access revisions |
| Unknown actors remain data. Foreign source identity, malformed lists, unsafe accessors and oversized input stay held. Repository activity still requires a repository. | [unit] src/channels/githubInstallationActivity.test.ts::GitHub installation activity normalization::retains unknown actors and rejects foreign or malformed source facts; ::holds unsafe and oversized payloads without evaluating accessors; ::requires both repository delta lists and a valid configured source |
GitHub comment conversion
The inactive normalizeGithubActivity adapter converts top-level PR comments, inline comments and replies, and submitted reviews into the common SourceEvent format. It handles comment creation, edits and deletion, plus review submission, edits and dismissal. Human and bot authors use the same source-evidence kind. Native authors, bodies, URLs, repository IDs and original payloads remain source data. They do not become requester identity, task selection or worker authority.
The caller must first verify the raw-body signature and configured host/App/installation/repository. The converter checks the supplied installation and repository IDs against the native payload and checks link hosts. Source, delivery, object and revision IDs stay separate. Canonical native-object snapshots distinguish same-time edits and deletions without treating JSON property order as a change. The existing source-event codec rejects unsafe or oversized data and returns a detached record.
This module does not change the webhook receiver, acknowledge a delivery, start a listener or write an agent inbox. Existing comment/check/push handlers keep their current behavior. API baseline/recovery conversion, source ownership, relevance, echo matching and shared delivery remain pending. Enabled event actions must be checked against the GitHub webhook reference.
| Criterion | Proof |
|---|---|
| Human/bot and top-level/inline/review surfaces retain the same evidence kind, native author and original payload; inline reply/review IDs and null authors survive conversion | [unit] src/channels/githubActivity.test.ts::GitHub comment normalization::* |
| Stable native object revisions are independent of delivery IDs and JSON key order; same-time edits and deletion/dismissal state remain distinct source evidence | [unit] src/channels/githubActivity.test.ts::GitHub comment normalization::separates delivery identity…; ::retains deletions and review dismissals… |
| The converter uses existing receipt and job semantics: duplicate input returns its original receipt and changed data under one delivery ID is held | [unit] src/channels/githubActivity.test.ts::GitHub comment normalization::uses the existing source receipt… |
| Unsafe accessors, malformed identity, a foreign repository/installation or link host, and oversized records are held; ordinary issue comments stay outside PR conversion | [unit] src/channels/githubActivity.test.ts::GitHub comment normalization::holds…; ::ignores ordinary issue comments… |
Repository lifecycle facts
The inactive converter also accepts repository created, deleted, edited, renamed, transferred, archived, unarchived, privatized and publicized actions. Native repository ID defines object identity. Rename and transfer keep that identity; a different repository that reuses an old name cannot match the configured source. The record retains the new name, action, privacy/archive state, optional visibility/default branch, actor and complete native changes. Missing optional metadata stays null.
Archive and visibility action flags must agree with their supplied boolean state. These are source facts, not current access, PR membership, checkout changes or an instruction to start work. Canonical access and task-context checks remain required before later handling. This extends the existing converter without a receiver, producer, access-cache change or direct delivery call. The supported fields follow the GitHub webhook reference and Octokit types.
| Criterion | Proof |
|---|---|
| Each supported lifecycle action retains one repository fact without requester or PR authority | [unit] src/channels/githubActivity.test.ts::GitHub repository activity normalization::retains …as a repository fact… |
| Rename/transfer preserve native identity; reused names cannot match a foreign repository ID. Snapshots and revisions remain separate from delivery identity. | [unit] src/channels/githubActivity.test.ts::GitHub repository activity normalization::keeps native identity through rename and transfer and rejects a name reused by another repository; ::freezes native snapshots and separates metadata changes from delivery identity |
| Unknown optional metadata and bot/unknown authors remain source data; malformed or contradictory state is held and unknown actions stay unsupported | [unit] src/channels/githubActivity.test.ts::GitHub repository activity normalization::keeps unknown metadata and actor identity as source data; ::holds malformed or contradictory state and ignores unsupported actions |
PR state, review threads and commit comments
The same inactive converter also accepts PR opened, edited, reopened, closed, synchronize, converted_to_draft and ready_for_review actions, review-thread resolved and unresolved actions, and commit-comment creation. PR and thread updates use source-fact; comment bodies remain source-evidence. These kinds describe source data, not requester authority or a command to a worker. The supported actions follow the GitHub webhook reference and the native thread schema.
A PR fact retains native PR identity, title/body, lifecycle state, draft/merge fields and separate head/base repository IDs, refs and SHAs. Synchronization also retains before/after SHAs and holds a mismatch between after and the supplied head. A deleted fork keeps a null repository ID. A thread uses its native node ID; it does not borrow a comment ID. A commit comment retains its commit SHA without a fabricated PR number. Every record retains the full native snapshot. Delivery order does not prove current source state; overlap, recovery and ordering still need shared processing before activation.
| Criterion | Proof |
|---|---|
| PR synchronization keeps fork head/base and before/after identities; malformed source or branch identity is held | [unit] src/channels/githubActivity.test.ts::GitHub pull and commit activity normalization::keeps fork head…; ::holds malformed branch…; ::holds malformed PR lifecycle states… |
| PR lifecycle and same-head edits remain distinct from delivery identity; deleted forks and unknown actors retain explicit null values | [unit] src/channels/githubActivity.test.ts::GitHub pull and commit activity normalization::retains the snapshot…; ::distinguishes a same-head…; ::retains a deleted fork… |
| Thread resolution uses the native thread ID and retains each state; commit comments keep their SHA without becoming PR requests | [unit] src/channels/githubActivity.test.ts::GitHub pull and commit activity normalization::uses the native thread…; ::retains commit comments… |
Checks and commit statuses
The inactive converter also accepts check-run created, completed, rerequested and requested_action, check-suite requested, completed and rerequested, and commit status events. A status event has no action field. These records are source-fact; a request to rerun a GitHub check does not authorize a Switchboard action.
Each check keeps its native ID, checked commit, status, conclusion, producing App ID and PR associations. An association can be empty for a fork or name a later PR head. It does not select a PR or prove its current head. Commit statuses retain their native ID, context, state, description and optional external CI link. Full native snapshots remain available. No receiver or delivery caller uses this converter yet. The field contracts follow the GitHub webhook reference and Octokit's webhook types.
| Criterion | Proof |
|---|---|
| Checks retain their exact commit separately from stale or empty PR associations, and preserve unknown actors without choosing a PR | [unit] src/channels/githubActivity.test.ts::GitHub check and status normalization::keeps …; ::keeps a fork check… |
| Status events need no action field and retain external or empty CI links; check transitions and action requests have distinct revisions independent of delivery identity | [unit] src/channels/githubActivity.test.ts::GitHub check and status normalization::retains status…; ::retains the checked commit…; ::separates check transitions… |
| Malformed source, commit, state or PR association is held; unsupported check actions are ignored | [unit] src/channels/githubActivity.test.ts::GitHub check and status normalization::holds… |
GitHub ref activity conversion
The same inactive converter accepts push and delete events. A source fact names the configured native repository and full ref. Branch and tag names stay separate. A delete event supplies a short ref name; conversion adds its type prefix without removing any literal prefix from that name. Push and delete facts for the same repository/ref use the same object identity.
Push facts retain before/after SHAs, creation/deletion flags, the force-push flag and optional base ref. Zero SHAs remain sentinels for ref creation/deletion, not selected commits. Delete events supply no SHA, so those fields remain null. Source actor identity is data; pusher metadata cannot become requester authority. Full native payloads remain retained and byte-bounded by the existing source codec. Conversion does not claim a complete commit list or infer PR membership, current head, event order or permitted continuation.
These fields follow the GitHub push and delete webhook contracts. The receiver remains disconnected from this converter. Native source matching, API repair, access-change handling and runtime delivery still remain pending.
| Criterion | Proof |
|---|---|
| Push facts retain exact ref and SHA data, including force pushes, creation/deletion sentinels and unknown/bot actors, without choosing a PR | [unit] src/channels/githubActivity.test.ts::GitHub ref activity normalization::retains branch SHAs and force-push facts without selecting a PR; ::keeps creation and deletion zero-SHA sentinels without guessing a commit; ::preserves unknown actors and treats bot actors as source data |
| Push and delete use the same full-ref identity. Branches and tags remain distinct; short delete names keep literal prefixes. Delivery identity and immutable revisions stay separate. | [unit] src/channels/githubActivity.test.ts::GitHub ref activity normalization::keeps delete facts on the same full ref and preserves absent SHA facts as null; ::keeps branch and tag identities separate and does not strip literal ref prefixes; ::separates delivery identity and changed ref snapshots while freezing input |
| Invalid ref fields, contradictory zero-SHA transitions and foreign configured sources stay held | [unit] src/channels/githubActivity.test.ts::GitHub ref activity normalization::holds malformed transitions and foreign source identity while accepting a valid push |
Admission diagnostics
Organization admission adds diagnostic.traceId, a closed stage/reason and receiptObserved to the existing response. The trace ID identifies this HTTP request, not a fabricated agent run. Unavailable admission is an error trace even when no exception was thrown. A host exception returns an unavailable result without its private prose. Existing receipts, run URLs, explicit policy refusals and legacy single-shot responses retain their contracts. False receiptObserved is not proof of no durable effect. Trace reading still requires trace:read.
| Criterion | Proof |
|---|---|
| Organization replies preserve exact receipt/owner/refusal semantics and add only current-request diagnostic metadata, including unavailable outcomes | [unit] src/channels/http.test.ts::HTTP organization admission::*; src/channels/browserHomeInstallation.test.ts::browser installation::admits authenticated HTTP and MCP calls under their own durable owners without email stream joins |