Skip to content

Latest commit

 

History

History
1638 lines (1576 loc) · 113 KB

ARCHITECTURE.md

File metadata and controls

1638 lines (1576 loc) · 113 KB

Architecture

Triage is an app on Interchange. Interchange owns identity, tenancy, grants, credentials, workflow runs and audit. Triage is a hub composition, a client-driven portal, and the workflow and tool packages the hub publishes and deploys.

Components

GitHub, Bitbucket DC ──webhook──► Hub ◄──── portal (/api)
        ▲                          │ mail
        │                          ▼
        │      local sidecars: listener ──deploy──► manager ──deploy──► judge
        │                                             │  └──deploy──► code review (sandbox)
        └──── labels, comment, review, actions ───────┘
  • Hub (apps/hub): the stock Interchange hub plus one composition block: @corbits/webhooks at /api/hooks, @corbits/artifacts (its tables only; the hub mounts none of its routes), @corbits/cron. Its generic hook route answers 404 unknown_hook: @corbits/webhooks 0.3.1 fires a run without naming its sender, which the sender gate refuses. In the image it also serves the built portal, so one origin carries the portal, the API and the webhooks. Triage adds:
    • GitHub bridge: verifies GitHub's HMAC on /api/hooks/*, updates the tenant's repository list from installation events, and hands pull request, review, review thread, comment, check_run and check_suite events to the coalescer. A check event does not say whether its pull request is a draft or still open, so a window of check events alone reads that once at its flush rather than once per delivery. A pull request merged into the default branch marks the repository's other triaged heads stale; a push to the default branch that touches the policy file reads it again. Bitbucket Data Center has a provider hook of its own (apps/hub/src/provider-hooks). Both verify with @corbits/common/x-hub-signature, the scheme @corbits/webhooks 0.4 adds as verify: "x-hub-signature"; the hub runs an earlier release, so the bridges keep routes of their own.
    • Policy file: a repository's policy is .corbits-triage.yml (or .yaml) on its default branch, extends included, and nothing else; the hub stores no policy text. Every trigger mail and portal read takes it from there, naming the file and its blob sha as the source. The hub keeps each read in memory; a push to the default branch that touches the file (GitHub push, Bitbucket repo:refs_changed) reads it again, and a failed read is not kept. A repository without a file reads missing: a pull request event answers needs-setup and no manager is deployed for it. A refused file is invalid; a pull request event or the reconciler mails the listener policyUnreadable with the reason and no pack, so the manager marks the head failed instead of triaging it, once per head. A push that leaves the policy unreadable, and on Bitbucket Data Center any default branch push, triages every open pull request again; a push that changes the file triages them as turning triage on does. Policies once stored as check-pack/… tenant artifacts are archived, not migrated, by the first hub start after the change (archiveStoredPolicies, through the artifact package's archive). Routes: GET …/policy/file (the file, its text and why it is refused, and while a policy pull request is open, its branch's copy as pull, or pullUnread when the pull request or its branch could not be read), POST …/policy/preview (edited text read as the policy read would read it once it is the file, merge risks included; writes nothing), POST /api/integrations/policy/validate (open, reads only its body), GET …/policy/upgrades (start or ok per enabled repository, each naming its host as source and, in either state, its open policy pull request as pull, or pullUnread where the shared open pull request listing was refused or could not list the repository) and POST …/policy/export, which opens one pull request on corbits-triage/policy: without text, the starter policy for a repository with no file (asking again answers the open one); with text, the edited file at its own path, refused while a policy pull request is open, when the file is no longer the blob sha names, and when triage would refuse the text once merged, extends resolved. GET …/policy/push asks the host whether the hub may push to the default branch: on GitHub the branch must not be protected and every ruleset rule on it must be one a single signed fast-forward commit passes; on Bitbucket Data Center no read-only or pull-request-only branch permission may apply to it unless it exempts the connected account by name (unreadable permissions count as no). POST …/policy/push skips review, so it also needs stock create on grant:* or manage on the workspace, and logs who pushed; it writes exactly the file, at its own path, as one commit with the message chore: update the Corbits Triage policy through the App (GitHub) or the account (Bitbucket Data Center), after asking that, refused when the file on the branch is no longer the blob the editor loaded; the push then reaches triage as any push does. POST …/policy/patch changes only the requested parts of the existing file, so comments stay, and opens that same pull request, or adds the batch to the open one's body under its title. A patch preview returns the file before and after and writes nothing. The file, export, push and patch routes take the repository's host as source; without it, a name connected on both hosts is refused.
    • Coalescer: one pull request's burst of webhook events goes out as one mail to the tenant's live listener for the repository's host, as the tenant system sender, the same path cron uses, naming the host as repoSource. A workspace with no live listener runs nothing: the head fails saying Triage is not running on that host and that Settings → Triage shows why. A head whose mail reached no listener, because the host had none, the listener was still starting or the hub held no route to it, is also marked unrouted and goes out again (see the reconciler). Every window is mailed when it closes, whatever the head's CI: the manager and the reconciler hold the CI wait (see triage-manager-github below), and a window that sees a check finish reaches the pull request as one more event.
    • Reconciler: one per host, for workspaces whose listener for the host is live. On an interval it compares each enabled repository's open pull requests with the managers' runs, their recorded verdicts and stored state, and re-queues every head without a verdict, and every stale head at most once per 15 minutes, under a per-tenant in-flight cap. A repository's open pull requests are listed again only once a webhook named it, until a minute after that webhook since the host's listing can trail it, or its last listing is 30 minutes old. Once its webhook heard nothing for 30 minutes (on GitHub the App's, for every repository; on Bitbucket the repository's own) it is listed every pass. It reads the CI of a settled head whose verdict CI left unsure or failed and queues it again once its builds changed. A head whose manager is up, as one waiting for its CI, is not mailed again. It mails its heads to the listener. A listener counts as live while it runs or is on its sidecar, and as starting for six minutes after it is deployed, longer than a roll waits for it; the reconciler mails the newest running listener, else the newest starting one. A listener on its sidecar stays deployed until its first pull request arrives, so it is never ended for not running. One deployed without a sidecar past that is released, and a host left with no live listener has its workspace rolled again. The workflow-versions route reports each deployment's live as a manager's is counted, which Settings reads.
    • App Manifest conversion: GitHub returns a one-time code that must be exchanged server side, so the private key never reaches the browser. The state round-tripped through GitHub is signed by the hub and bound to the user who started setup, so no setup state is stored.
    • Integration routes under /api/integrations/, each described in /openapi.json: sign-in methods, GitHub App setup and installation sync, open pull requests, details, merge states, actions, held actions and single pull request triage, verdicts, stats and timeline, the policy schemas and routes above, invitations, workflow deploy and versions, Bitbucket Data Center, and code review. GET …/github-triage/{tenantId}/policy?repo=&source= answers a repository's policy as triage reads it on that host (GitHub without source), with the action kinds it uses (wanted) and the merge risks of its automatic approve or merge computed there, so the portal offers the grants and shows the risks for GitHub repositories without reading the policy itself, and POST …/github-triage/{tenantId}/backlog starts backlog triage, so only the hub resolves policies and builds trigger mail. GET …/merge-states/{tenantId}?repo=&numbers= (at most 100) says whether the host would merge those pull requests now: one GraphQL query on GitHub, each one's merge checks on Bitbucket Data Center. The portal asks about passes only and fails closed: a pass is ready to merge only on the host's explicit yes (GitHub clean, unstable or has_hooks, Bitbucket canMerge); anything else not explained is "Checking merge state". Pull request actions are synchronous: a maintainer's click must not wait on a workflow spin-up. GitHub's answers to open pull requests, details and merge states are shared by the workspace and kept per repository until a GitHub webhook about it, or the hub's own write to it, arrives; check and push events leave the open pull request listing kept. A lost webhook costs at most KEPT_MS in src/github/repo-reads.ts: longer for the listing, short for details and merge states, which also follow commit statuses the App is not sent. A mergeability GitHub has not worked out yet, or a block without its review decision, is never kept, and at most MAX_KEPT reads are kept. Bitbucket Data Center is read live.
    • GitHub calls: every hub request to GitHub passes one gate. It logs a github_calls line each minute with the calls by route and installation and the last rate limit GitHub reported. Once GitHub rate limits an installation, the gate refuses that installation's requests without calling GitHub until the reset; a request that names no installation is counted but never refused. Routes answer 503 github_rate_limited with Retry-After. The reconciler logs the limit once and schedules a pass for the reset; earlier passes make no calls to that installation. A webhook whose pull request or CI cannot be read for the limit is not mailed; its head is left for the reconciler.
    • Request log: the /api/integrations/ routes, and requests the hub refuses before routing (an undecodable path, an unverified account), log method, path and status on the stock hub.requests category. The query is never logged: the manifest callback's carries GitHub's one-time code. A refused workflow deploy also logs workflow_deploy_refused with its code and reason.
  • Sidecars: started by the hub's SidecarProvisioners. The local-process provisioner runs each allocation in a sidecar process of its own, a child of the hub, as stock's own harness does: ensure starts it under the identity Interchange offered, with a manifest and a data directory of its own, and destroy stops it and removes both, so cleanup is always confirmed. A sidecar process keeps the memory of every workflow it loaded, so none hosts more than the one deployment it was started for, and each advertises one active deployment. A late ensure for an identity a destroy named is refused from memory; after a restart Interchange offers every ensure a fresh identity. Boot never starts a sidecar again: it retires every sidecar a manifest names, so Interchange places their deployments again at once, and removes the manifests and data directories. A boot that cannot retire one fails and keeps them for the next. A sidecar that exits on its own is retired at once. The provisioner starts at most TRIAGE_MAX_LOCAL_SIDECARS (default 8) sidecars, and one only while 1 GiB is free beside those started that have not connected yet; past either it refuses the ensure as retryable capacity_unavailable. The hub's placement chooser (apps/hub/src/sidecar/placement.ts) counts the local allocations and probes, and the placements waiting for a slot, and sends a deployment to the first matching sandbox provisioner once the cap is reached. With no sandbox it stays local, where stock retries its placement with backoff (1, 2, 4, 8 and 16 seconds, then 30) for up to 10 ensure attempts, about two and a half minutes, and then fails it. Stock keeps that reason only while the placement waits, so each provisioner's refusal for capacity_unavailable is recorded as it answers (apps/hub/src/sidecar/refusals.ts) on the claim whose last deployed manager it was; the listener names each manager it deploys through POST /api/tenants/{tenantId}/manager/deployed, under the deployed grant on triage-manager:state, and a run refused before it is named is read as refused from its allocation then. A manager taking the claim clears the refusal. While no manager has run since, the reconciler keeps the head queued with waiting for capacity as its reason: it is never marked never started, spends no attempt and has no backoff, and every pass asks for a manager again, at the pace the claim's hold on a recent deploy ask allows, until one is placed. The manager never ran, so the pull request gets no comment or label, and the portal shows no failure for it. A manager deployed after the refusal that is not refused ends the wait, and the head then ages and backs off as any other. Probes run on a local sidecar only when no sandbox is registered, since with one they require isolation:workload. The Docker provisioner (packages/provisioner-docker), registered after it when a daemon with the sidecar image answers, runs each sidecar in a container of its own. A published hub image carries the digest of the sidecar image CI pushed just before it, and the provisioner pulls that image when a placement finds it missing. The provisioner declares isolation:workload; workflows that require it land there, and probes then require it too. The E2B provisioner (packages/provisioner-e2b), registered after Docker when E2B_API_KEY is set, does the same with one E2B sandbox per sidecar. The Railway Sandboxes provisioner (packages/provisioner-railway), registered after E2B when its token and environment are set, does the same with one Railway sandbox per sidecar. provisioners/ describes each. Code review requires isolation:workload; without Docker, E2B or Railway Sandboxes, stock Interchange refuses it with provisioner_no_match. Stock replaces a sidecar that misses its connect deadline only when the classifyParkedRun hook it is given answers parked; otherwise it releases the sidecar and fails the run. A Railway sandbox's deadline is the five minutes the provisioner waits for the VM to be running. Every other sidecar keeps 120 seconds. The hub uses that hook (apps/hub/src/sidecar/unstarted-run.ts) for a sandbox whose run never finished deploying, which has no public key on its run, stock's proof that a generation deployed it: it answers parked until the run has had three sidecars in all, so stock replaces the sidecar on the same provisioner after its replacement backoff and the run keeps waiting. A run that deployed, or has had three, gets stock's own answer and fails sidecar_connect_failed as before. GET /api/integrations/code-review/{tenantId}/place names the provisioner stock's plugin registry selects for it under the workspace's placement policies, or null, and lists every place the hub's provisioner packages describe (DOCKER_PLACE, DOCKER_REMOTE_PLACE, E2B_PLACE, RAILWAY_PLACE) with the state the hub found at boot, for Settings → Code review. The hub's own machine is never one. While it is null Review again refuses (no_isolated_place), nothing is mailed, and the repository page links Settings → Code review.
  • Portal (apps/web): owns setup. It writes the tenant, credentials and inference offerings over stock hub routes. Its build bundles the workflow sources, which the hub publishes and deploys (see the listener below). It is a wrapper over the routes; nothing is possible only through it. Each minute and on focus it reads the served index.html; once its hashed entry script differs from the one the tab loaded, it offers a reload.

Lifecycle of a pull request

What happens to one pull request, end to end. The sections after this one are the detail of each step.

  1. Event in. A host webhook or the reconciler names a pull request head, and the hub records the event. A repository without a policy file answers needs-setup and gets no mail, and a pull request carrying the policy's pause label (GitHub) is not mailed. When the pull request's manager is up, the hub mails it directly. Otherwise it mails the host's listener; a head whose mail reached no listener is held in its triage-state row as unrouted and sent again once one takes mail. For a policy file it cannot read the hub mails the reason, and the manager marks the head failed.
  2. Listener. It gets only pull requests with no manager up. For each it deploys a manager and wakes it at once; stock holds the mail until the manager's deploy is recorded. A manager that never comes up loses the pull request once that hold lapses, and its next event or the next reconcile pass asks again. The claim on the pull request lets one manager run for it at a time.
  3. Policy. The manager triages under the policy the hub resolves from .corbits-triage.yml on the default branch, the only source.
  4. First post. Unless the first pass decides, the manager posts at once that the head is in progress, naming what it waits on.
  5. Three isolated parts, each with its own limits, none of which writes to the pull request. The deterministic checks run in the manager on every pass and read every patch up to their own ceiling, whatever the judge's limits. The judge is its own workflow with its own budget, deployed when the rules need it once the host's checks have finished, or once the manager stops waiting for them. Code review is its own workflow in a sandbox, deployed once the rules gate opens.
  6. Waiting. While the judge or code review is out, the manager waits for it, editing its one comment in place. A head that waits only on CI posts that it waits, and its manager ends; the reconciler re-reads the head's checks and queues it again once CI settles, and a fresh manager carries on from step 1. A head is decided with CI still running after it has waited an hour, and a manager stops waiting for anything three hours after its claim: it decides, says what it stopped waiting for, and runs no action on its own.
  7. Final post. The verdict label, the comment's final state and the code review go up, then the policy's actions. Each action runs where the pr-triage role's grants, written from the policy file, allow it, and is otherwise held for a person, who takes it once or allows that kind in the repository from then on. With code review on, each action whose step's when.findings the head's review does not meet is held for a person too (see Policy blocks).
  8. End. The manager ends after step 7, or while its head waits only on CI. A new head while it is alive restarts its pass on that head. A pull request with nothing outstanding has no manager; its next event starts again at step 1.
  9. Where it runs. Each deployment, the listener, a manager or a judge, runs in a local sidecar of its own, up to TRIAGE_MAX_LOCAL_SIDECARS (default 8) at once; past that a deployment goes to a sandbox. Code review always runs in a sandbox.
  10. Invariants. Workflows are deterministic, effects are idempotent under their effect ids, nothing repository-specific is written into workflow code, and size is a risk signal that labels a pull request, never a hold.

The workspace's automation switch (corbitsTriage.automation, policy or ask) is a brake for the whole workspace: under ask the grants hold only posting, so every other action is held, and a repository earns automation one kind at a time through Always approve for this repository (named after the kind) on a held action.

Workflows

Each is an onTrigger mail listener: each mail spawns a child run that executes and ends. The bridge and the reconciler find the target by workflow name per tenant; no deployment ids are configured anywhere. Each declares mailAccept: { tenant: true }, so Interchange's sender gate admits the hub's github sender (a principal in the deployment's tenant) and refuses mail from any other tenant or an unknown sender.

  • repo-scan: one run that reads a repository's most recently updated closed pull requests (200 by default) and measures what maintainers merged and closed. The hub does not deploy it.

  • triage-listener-github and triage-listener-bitbucket-datacenter (triageListener in @corbits/triage-manager): a workspace's listener for one host. The hub mails it the pull requests it recorded that have no manager up (items); for each, it deploys a manager through the stock deploy route with its run token, from the manager's source asset at the commit and the offerings the hub publishes beside it in release.json, read as each body runs, and wakes it with a mail that names only the pull request. A pull request whose manager is up is mailed that manager instead, so a later webhook does not deploy a second one. The listener mails the new manager at once: stock's mail hold (hub-sessions sidecar-handler.ts) parks mail to a run whose deploy is not recorded yet and delivers it once the deploy records the run's key. The hold does not end early when the deploy fails and does not tell the sender; it drops the mail after HUB_MAIL_HOLD_TTL_MS (fifteen minutes by default). A failed manager start is therefore recovered by the hub's grace and the reconciler, bounded by the hold: the hub's start grace for a just-deployed manager, the time one may sit on its sidecar unstarted and the claim's hold on a new ask (deployAskedAt) are all HUB_MAIL_HOLD_TTL_MS. The ask's hold ends sooner once the run the listener deployed for it is one stock no longer lets execute (canExecuteWorkflowRun), as a failed or cancelled start is, so the pull request's next event asks the listener for another at once. Otherwise, once the hold lapses the manager has lost its pull request, and the next event, or the reconcile pass after the hold (a queued head counts as never started after the hold plus one TRIAGE_RECONCILE_INTERVAL_MS), asks the listener for another. A run stock no longer lets execute, as one whose cancellation is requested or whose deadline has passed, is mailed nothing and its own take is refused; one that holds the claim keeps it until it ends, so no second manager writes to the pull request meanwhile. The pull request's next event, or the reconciler re-queuing its head, which has no verdict, deploys a fresh manager that claims it. A mail that is not the hub's, that names no pull request, or that finds no manager in release.json fails its body. Built with the release the module loads, its definition declares workflow:* create and read on exactly the manager's asset, sourced from its creator; built without one, neither. The hub publishes and deploys it (apps/hub/src/workflow-release/). A roll of a workspace runs in the background of what triggered it: at start, for every workspace holding the pr-triage role; once a workspace's credential or catalog writes through the stock routes go quiet for three seconds, so a key saved as several writes rolls once; when the GitHub App is connected or its key replaced, a Bitbucket Data Center instance is connected, its token replaced or its webhook secret rotated, and when GitHub installations sync; and when a reconcile pass finds a connected host with no live listener, or with one whose deploy or sidecar restore missed the five-minute start deadline or whose connected sidecar the hub holds no route to. At most two rolls run at once across the hub, and one per workspace across hub processes, under a Postgres session advisory lock held on a connection reserved for it, so a roll holds no transaction while it waits. A hub whose build has no packages/sources/index.json logs workflow_release_unbundled with the path at start and rolls nothing. A roll reads the workspace's catalog the way stock's model discovery does and leaves out a host while the workspace has no decision model. It publishes the portal build's sources (packages/sources/ under PORTAL_DIR, or apps/web/public/ when the hub serves no build) in the build's order as source-format workflow assets through the stock asset service, as the workspace's triage-publisher system principal, which it gives the Deployer role; once a roll leaves every host's listener running, the role is taken from every person who holds it. Each host's code review, judge, manager and listener publish in that order: the manager's tree gets a release.json naming the code review and judge just published and the offerings each runs on, the listener's the same for the manager. The manager's release indexes code reviewers by workflow name for each host. Before either early or normal review deployment, the policy provider and host select that exact entry, whose recorded name must match. The manager declares creator-sourced reads for each published reviewer asset, never a wildcard. Older single-reviewer releases remain usable only when their recorded workflow name matches; an indexed catalog never falls back to that legacy slot. Releases without the reviewer's identity must be republished. A chain workflow publishes only while the workspace has every model it names, so code review publishes once its key is saved, and the release after that gives the manager and the listener new assets. An asset is named <workflow>-<publisher>-<8 hex>, the build's content hash or, for a workflow with a release, a hash of it and its release.json. An asset another principal created is refused, and one already published keeps its commit only while that commit's tree is the release's files; any other tree fails the roll. The publisher gets read on the asset of each workflow another one deploys, since stock gives a creator none and a listener deploys its manager under its creator's read. The roll raises the tenant's lifecycle.maxLifetime to the longest Interchange accepts when it is unset or lower, since stock takes no lifetime per deployment and the definition does not exist before the first one, so a listener lives until a newer one replaces it. A host whose newest live listener runs the published listener's commit keeps it without waiting on it, and the host's older listeners stop; otherwise, and when a write changed that host's credentials, the hub deploys the listener through the stock workflow allocation service with the publisher as its source authority, waits for it to be live (cancelling it after five minutes), then cancels the host's other live listeners and every live deployment of the workflows the listener replaced: pr-triage, pr-triage-historical, the standalone code review and the PR workflow variants. A model key or catalog write deploys every host's listener again, a host's own credential only that host's. Until the new listener runs, the hub mails pull requests to the one it replaces, and a listener counts as starting, not stuck, for longer than a roll waits for it. A failed roll leaves the previous listener running and is tried again about ten minutes later, as is a roll that finds another hub process holding the workspace; the hub logs workflow_release_failed with the cause, and the workflow-versions route reports the host, when the roll ended and, when it failed, a fixed sentence under rollouts, which Settings → Triage shows beside each host's running version. A published workflow's asset, and so its definition, is named for its publisher and release, so the hub finds a deployment by the workflow id in its frozen projection (apps/hub/src/github/deployment.ts), and the workflow-versions route reports a source deployment's asset, commit and package from its closure. A host's listener and its managers are released once the host is disconnected: right away when Bitbucket Data Center is disconnected through its route, and on the next reconcile pass when the GitHub App credential is removed. A running listener keeps the credentials it launched with, which is why a credential write deploys its host's listener again; managers and judges launch with the current value.

    Stock routes a deployment's mail through the sidecar router's address index, per allocation generation, clears its public key while replacing its sidecar, and acts on a lost route only when a sidecar connects or reports a stop. Its disconnect limit does not run while the allocation stays allocated, which is the row a sidecar leaves behind when it goes offline without a stop. Each reconcile pass therefore checks each host's live listener: while its deploy or sidecar restore has not finished (no deployed allocation), the hub measures how long the pass has seen that run unfinished and, once past the five-minute start deadline, starts the host's listener again and logs listener_unroutable with unstartedMs. Once the generation's deploy completed, the pass asks the router, through isAllocatedWorkflowActive, stock's deploy-readiness check, which reads the same per-connection route table mail is routed by, whether it routes that generation. Like stock's own reconciler calling it, the check undeploys a sidecar whose identity is no longer current. When the listener's sidecar is connected but the router holds no route to it, the hub starts that host's listener again exactly as a credential write does and logs listener_unroutable with the workspace, host, run, allocation, generation and reason. A host whose roll is running or already due to redeploy it is left to that roll, so one unroutable listener is replaced once. When the router reports a listener's sidecar not connected, its SidecarNotConnectedError carries notConnectedSince: when this hub last lost that generation's binding, or when the router started, so a hub restart starts the measure over. A disconnect that has not lasted longer than the deployment's maxDisconnected (maxDisconnectedMs on the allocation) only logs listener_disconnected at debug. Once it has, the hub starts that host's listener again the same way and logs listener_unreachable at warn with the workspace, host, run, allocation, generation, how long it was unreachable (unreachableMs) and the reason. One whose roll is already running or due is left to that roll, so it is replaced once. listener_identity_unknown is logged when the router could not validate the sidecar's identity, and listener_check_failed when the check failed any other way, such as a generation that is no longer current; neither pass is a disconnect. After asking stock to stop the listeners and workflows a roll replaces, the roll polls stock's lifecycle status until each ends. One that stays live once stock gave up stopping it, which stock's lifecycle status shows as its allocation no longer dispatchable, one still live five minutes after the stop was requested, and one whose status could not be read after three retries are logged and the roll goes on: a listener as listener_cancel_stuck, which every reconcile pass logs again while stock has given up on it, and a replaced workflow once as rollout_cancel_stuck with its name.

    Mail is addressed to a listener's run, and stock holds none for a run that has gone, so a head whose mail reached no listener is held in its triage-state row instead: the row fails with unrouted. That is mail sent when the host had no listener, or that its listener took none of because it was still starting, stopping or gone, or the hub held no route to it; mail a live manager did not take fails as usual. Before recording a pull request's event, the intake asks stock's router whether it routes the listener, when a pull request named has no manager up, so mail the listener cannot take records nothing. A held Do's approval is the exception: it is recorded all the same, since nothing a person did is dropped and no row holds it, and the manager the listener later starts for the head runs it. A request a person makes while the listener takes no mail answers 202 and its heads are held the same way: Triage again holds its head, backlog triage holds each open pull request's head, drafts left out, and a held Do's approval holds the head it was made on. Each starts a pass at once. The held head is triaged again when its mail goes out, on the same head, so the judge's verdict for it is reused and the verdict comment is replaced in place. The reconciler queues an unrouted head on its first pass with a listener up, without backoff, and those tries add nothing to the head's attempts. A coalescer mail that reached no listener starts a pass at once, whose listener check rolls a workspace left with none, and a pass whose mail for a head reached no listener runs again 15 seconds later, for at most 20 passes in a row per head; past that the head logs triage_unrouted_waiting once and waits for the ordinary pass. A listener taking the head's mail, or a roll finding the host's listener running, starts those 20 passes over. A roll starts a pass as soon as its new listener runs, logging workflow_release_live, before it waits for the listeners it replaces to stop, and also when it finds the host's listener already current and running. A base branch move clears unrouted, since it decides when each head goes out again. A pull request opened while its listener is replaced is therefore mailed as soon as the new one runs.

  • triage-manager-github and triage-manager-bitbucket-datacenter (triageManager): one live run per pull request, which lives until its head has its final verdict or waits only on CI, and the only workflow that writes to the pull request. It takes mail from the hub (tenant) and the listener that deployed it (invoker). A mail starts a loop of passes. Each pass takes, which claims the pull request and its pending events and answers the newest pull request mail the hub recorded. Each held Do approved since runs through the shared approval step, which skips one whose pull request moved past its head. Then, when the pass has a mail for its head, a judge or code review report, or an overdue reviewer, the shared triage steps run on the newest mail for the head, handed over as { taken }, which they trust as the hub's. A head is unready first: on a pull request mail on GitHub its pass label comes off (markUnready) before facts are read, unless every triage event on that mail is a person's approval, which leaves every label in place, including when the pass then waits. A push, an open, a synchronize, a check, a comment, a catch-up, or any of those mixed with an approval still takes the pass label off. Every pass runs its approvals step, which approves nothing when there is nothing to run. A policy of only deterministic rules posts on this pass. One whose rules need the judge, or that asks for code review, posts once, after each of those has reported or given up: the machine verdict, the judge's verdict and the code review together. Whether a head waits on the host is read from its facts, never from rule results (hostAwaited, logic/render.ts): CI still running (checks: "pending") where the policy reads CI at all (CI green, or a step's CI condition, approve or merge, dependsOnCi), or mergeability not computed (mergeable: null) where no-merge-conflicts is on. Such a verdict, Fail aside, waits: its pass writes only the waiting marker and records no verdict, and the judge is held. Otherwise CI never holds a verdict. Where the role holds Comment, a pass that ends waiting writes the one triage comment as Triage: In progress, naming what the head waits for (CI, the judge, code review), through the same writer as the verdict's comment (mirror_auto); a pass that ends waiting for a reviewer writes it in its progress step before its done. Every such pass writes it, and the host leaves a comment that already says the same as it is, so it changes only when what the head waits for does; the verdict then edits that comment in place. A pass that deploys code review while CI is still awaited reports the CI wait too, so its 60 minutes keep running. Done reports what the pass ran: the approved Dos' outcomes, a head that waits for CI, or the verdict it posted as its evaluate, mirror, execute and rules steps output it, with the facts the verdict keeps rather than the diff. Each branch that reports one of these has its own done step. A pass asks done to wait only while a judge or code review it mailed for its head is out and within its deadline. Done then answers more when an event is pending, and the next pass runs at once; more with wait after a pass that asked, and the next pass runs once 30 seconds have passed, taking whatever arrived meanwhile; otherwise ended. A head that waits only on the host (its CI, or mergeability) posts that it waits and its manager ends, so no manager sits on a sidecar for CI; a reviewer's result that lands while the head still waits on the host posts the waiting state the same way, and the judgement stays on the reviewer for the pass after CI. A check finishing, a reviewer's result or a new head reaches the hub, which mails the live manager or has the listener deploy one, and a new head's pass starts over. The listener is asked once per manager start: the claim keeps when it was asked (deployAskedAt), and until a manager takes the claim, or for four and a half minutes at most (both of the listener's wakes, below), a further mail is only recorded for that manager to take; a mail that failed to reach the listener withdraws its ask. A pull request with nothing outstanding has no manager. The hub keeps, on the claim, whether each closed pass waited for CI, and answers take with waitingSince, when the head's unbroken run of waiting passes began, and headSince, when the head's first closed pass was taken. Until 2 minutes after that (CI_START_MS), a pass sends ciStarting, and where CI green is on facts read a head with no checks as checks pending ("checks have not started yet"), so it waits for its CI to start. For a head whose manager ended to wait, the triage reconcile pass reads the head's checks once per pass (waiting_ci_read logs how many) and queues it again (triage_requeued, reason: ci_settled) once CI is no longer pending, the head still has no checks past the start grace, or it has waited 60 minutes (CI_WAIT_MS), without reading. That settles a Bitbucket head, whose builds send no webhook, and a GitHub head whose check_run never arrived. A pass whose head has waited 60 minutes is triaged with noWait, so it is decided with CI still running. The portal's Waiting on CI comes from the claim's last pass on the newest head, whether or not its manager is up. A manager lives three hours from its claim (claimedAt, which take answers); a new head does not extend it. Since a manager ends while it waits only on CI, the clock bounds one its reviewers keep alive, such as a judge or code review deployed again on each new push. The pass after that stops waiting (stopped on take): it takes every reviewer still out as past its deadline and decides with noWait, and its verdict says what Triage stopped waiting for and holds every Do for a person. The loop's pass count is only a backstop. A manager holds one of the TRIAGE_MAX_LOCAL_SIDECARS local sidecars only while it runs a pass or waits on a reviewer, and its judge and code review take more. Past the cap, deployments go to the sandbox provisioner; with none configured they wait on stock's placement retry and then fail, and the pull request is tried again on its next event or reconcile pass. That is the bound (CL-11587). A live manager waiting for CI with a reviewer out is parked: the reconciler does not count it toward the tenant's in-flight slots, which only managers judging or reviewing hold. Once the passes end, fail or run out, or take finds another run holds the claim, the run's last step stops it through the stop-run route under a manage grant on its own run, so the run is ended once and never keeps the claim: the next manager's take finds the run gone and claims. A run left deployed with no sidecar for HUB_MAIL_HOLD_TTL_MS (fifteen minutes by default) is gone too, and its cancellation is requested so it does not start after the claim has moved on. A manager or judge is deployed with the mail that starts it, so one still deployed that long after it reached its sidecar never ran, its mail dropped by stock's hold: it is gone for the claim, and each reconcile pass ends it and logs triage_deployment_never_ran with its run id and workflow. The hub times that from the sidecar's connection for the run, or from its own start for a run placed before it. When the rules need the judge, the host's own checks have finished (or the manager waits for them no longer) and release.json publishes a judge for the host, a pass deploys it under workflow:* create and read on exactly the judge's asset, which the definition declares when its module loads with that release, mails it the rules output once its run is ready, giving up as soon as the run ends first or at the judge's one-hour deadline, and posts only the in-progress comment. While those checks run, the judge is held and the pass marks the head waiting, as a pass of deterministic rules does. The pass that collects the judge's report posts and executes its verdict, once that verdict names the pass's pull request, head and host: its judgement as the judge made it, with its Dos made again from the pass's facts (verdictOnFacts), so CI that finished on the same head since decides them. Facts and rules still run, and when they no longer need the judge (a failed hard rule, a draft or a closed pull request) the pass's own verdict goes up instead. A decided verdict, one that does not wait and whose machine checks all confirmed, stays on the judge's reviewer on the claim for its head, so a later pass on that head posts it again on its own facts and deploys no other judge; a new head gets its own. A manager that stopped waiting deploys no judge: it decides without one. A verdict the host's checks could still change, a degraded one or one over 256 KiB still goes up on the pass that collects it, and a failure or a missed deadline leaves the machine verdict to go up alone; none of them is kept, so the next event deploys the judge again. The in-progress comment for code review that follows the pass's verdict is written with keepVerdict set to that verdict and its policy ref, so the host's mirror leaves a comment whose marker shows the same verdict and policy for that head as it is and reports kept: "verdict". The marker says what the comment shows: a verdict write sets shows: "verdict", and a progress comment keeps the header of the verdict it replaced, for the next verdict's note on what changed since, with shows: "progress", so it never counts as showing that verdict. A comment written before the field is read as showing its verdict. While a judge is out on an automatic pass, its progress is written with keepVerdict set to the machine verdict, the one verdict would post were the judge to fail, made from the rules alone with no model call, so a verdict the rules still give stays until the judge reports, and one they no longer give is replaced; a verdict the rules no longer give, or one made under another policy, is replaced as before, and CI running again on the head or a Review again shows progress over a verdict. A judge the release does not publish posts nothing. When the policy turns code review on, the verdict's rules let it past, the hub read the head's review request and the release publishes a code review, a pass deploys it the same way (without one it declares no code-review asset, and the verdict goes up alone), mails it the request once its run is ready, giving up as soon as the run ends first or at the review's one-hour deadline, and still posts only the in-progress comment while that review is out. A pass that mails the judge deploys code review with it when the block runs whatever the rules say (when.rules: any), since under pass the judge's rules could still shut the gate, unless when.triage leaves out a verdict the judge has still to decide or it is a Review again; the review is still posted with the final verdict. A pull request whose failed rules shut the rules gate, or that the block's for, when.triage or when.paths leaves out, read from the rules step's facts and the verdict, is not reviewed; done reports why on the verdict as codeReviewSkipped, which the portal shows as Not reviewed. One triage could not read is not left out: its review waits while for or when could leave it out. The pass that collects its report mirrors the verdict, then posts the review through the host's send_code_review tool, so they go up together. A review stays on its reviewer for its head like a decided verdict, so a later pass on that head whose rules still let code review past posts it with the verdict and deploys no other; a review that could not be mailed, reported a failure or ran past its deadline leaves the verdict to go up alone and stays on its reviewer as failed, so a later pass on that head keeps the verdict and deploys no other until the head changes or a Review again asks for one. Each reviewer keeps its head and deadline on the claim, so whichever manager takes the next pass on that head sees a deadline that passed with no report. A pass after one that left reviewers out first reads each one's run through the stock run read, before take, so a reviewer that reported before its run ended has its report in take's events; take treats one whose run ended with no report as past its deadline, and a read the hub does not answer leaves the reviewer out. Either way the manager posts the verdict without that reviewer, which for a judge is Needs Human Review and for code review holds approve and merge for a person, and done logs reviewer_overdue. The triage reconcile pass, every 5 minutes, wakes a manager through createManagerWake for each pull request whose manager is not up while a reviewer of its newest head is past its deadline with no result recorded (createOverdueWake, apps/hub/src/manager-intake.ts), and logs manager_woken_overdue. A wake that succeeded is recorded on the claim (overdueWokenAt), and the next pass wakes again only once the manager start grace has passed: HUB_MAIL_HOLD_TTL_MS when a manager has taken the claim since, thirty while none has, so a manager slow to reach a sidecar is never deployed twice. Each pull request is woken on its own: one whose wake fails is logged as manager_overdue_wake_failed, the rest are still woken, and the next pass tries it again. The same pass wakes a pull request whose manager is not up while a reviewer result no pass has taken waits for it, as after the reviewer-result route could not wake one, once the manager start grace has passed since that result was recorded, so a manager the route's own wake deployed is not deployed again while it starts, and with the same hold after its own overdue wake. So within one pass of the deadline that manager posts the verdict without the reviewer and the pull request needs a person. Each reviewer the manager records names the manager run that deployed it (deployedBy). Done stops a judge or code review this run deployed that ran past its deadline, or whose run ended, on the pass's head, through the same stop-run route. Stock grants the deployer read on a run it deployed, and the vendored Interchange carries manage instead (vendor/interchange/VENDORED.md). A reviewer on an older head is stopped by the hub, which a later manager could not do: see intake below. The hub records the verdict done reports as the manager run's, in done's transaction (apps/hub/src/manager-routes.ts), with the shared row-building code (apps/hub/src/github/verdict-row.ts). The reconciler and Triage again read the managers of the pull requests they look at from their claims and those rows (apps/hub/src/github/manager-runs.ts): a manager that is up runs the head of the newest pull request mail recorded for it, so its head is not mailed again; one that went away with a pass open failed on that pass's head, which is retried with backoff; a recorded verdict settles its head. Triage again can queue a head again as soon as its manager has reported done, or went away, since it was queued. The hub also checks, every TRIAGE_LIVENESS_INTERVAL_MS (a minute by default) and apart from the reconcile pass, whether each workspace's pull requests still reach a manager, host by host (createTriageLiveness, apps/hub/src/triage-liveness.ts). The take route notes when a manager of each workspace and host last opened a pass on a pull request; a take that finds nothing to do does not count. As the hub starts it reads that back from the claims' newest pass takenAt. A check finds the oldest of the host's pull requests that still waits: a pull request mail recorded after that pass that no pass has taken, a triage state row queued after it, or any head whose mail found no listener or did not go out, which no pass can have taken. Such a head keeps when its mail first did not go out (undeliveredSince on its row) while the reconciler queues it again, and counts until a run of it is seen. Once that one waited past TRIAGE_STALLED_AFTER_MS (five minutes by default) the host is stalled: the hub logs triage_stalled, the workflow versions route lists the host under stalled, which the portal shows on every page and in Settings → Triage, and with SMTP set up the workspace's owners and admins with a verified address get one email naming the host. The alarm clears only once a manager of that host opened a pass after the alarm's since and nothing waits past the alarm age any more: the hub logs triage_stall_cleared and emails them once more. Without SMTP the hub logs triage_stall_not_emailed once. A workspace whose check fails is logged as triage_liveness_failed and the rest are still checked. Alarms and takes live in process, so a restart on a stalled host raises its alarm again on its first check. Approving a held Do of a manager's verdict reads the Do from the recorded verdict and sends it with the mirror request its pass kept; a Do the run's newer verdict no longer holds answers verdict_superseded.

  • pr-code-review-reviewer-github and pr-code-review-reviewer-bitbucket-datacenter (codeReviewReviewer in @corbits/pr-code-review): the code review a manager deploys, alone on its sidecar. It takes mail only from that manager and reviews the request. Stock runs a step whose dependencies failed, so however the review ended it records its StageResult through the reviewer-result route (a failure as failed), which wakes the pull request's manager, and stops. It posts nothing to the pull request. Its definition declares only create on triage-manager:result and manage on workflow-run:self, sourced from its creator: it can report a result and stop itself, and reach no other manager route.

  • triage-judge-github and triage-judge-bitbucket-datacenter (triageJudge in @corbits/triage-judge): the judge a manager deploys. It takes mail only from that manager. It runs the model checks the machine rules produced, then focused's follow-up questions, then evaluate. Stock runs a step whose dependencies failed, so however the judge ended it records a StageResult of kind judge through the reviewer-result route: the whole verdict evaluate made, its comment, labels, Dos and rule results, or a failure as failed, which wakes the pull request's manager, and stops. It posts nothing to the pull request. Its definition declares only create on triage-manager:result and manage on workflow-run:self, sourced from its creator.

  • A run sends mail only through an agent step's transport, so the listener and the manager mail through one agent whose director makes no model call: it runs its one tool, which waits for a just-deployed run to publish its public key and then mails it as the sending run, giving up as soon as the hub shows the run ended or at a deadline.

Triage pipeline

Each step below names the workflow that runs it; all but review are the shared steps of @corbits/triage-workflows. The manager runs facts, rules and route on every pass, and evaluate itself when no question needs asking. Otherwise the judge it deploys runs judge, parts and evaluate and reports the verdict. Code review is the separate workflow below, which the manager deploys once the rules gate opens. Mirror, review and execute are the manager's, the only workflow that writes to the pull request. The repository's policy decides what each step does. Before facts, a pull request mail from the hub naming a GitHub head mirrors unready:<owner>--<name>:<number>:<head sha>, which takes the pass label off that head, so it never stands on a head not yet triaged, unless every triage event on that mail is a person's approval: an approval leaves the labels already on the head, and a pass that then waits does not swap the pass label for Waiting for CI. Any other event, including a catch-up that names none, still takes the pass label off.

  1. facts (manager): reads the pull request, its files, reviews, CI, commits (the first 100 messages in full), the repository's docs and its contribution rules from the host. On GitHub the body is read from GitHub's own rendering (body_html, from the full media type) as plain text (renderedText in @corbits/triage-contracts): comments and anything else GitHub's markdown hides never reach it, nor attributes, diagram sources GitHub ships hidden, screen-reader-only text or ruby fallbacks. A link reads as its text, and only a link the linked-issue rule counts (a GitHub or Linear issue) keeps its target beside it, up to the issue's id, so the rule still finds it. Math is read as its source. Bitbucket Data Center shows the body as written. The title, body, commit messages and changed paths then lose controls and default-ignorable characters such as zero-width, bidi, tag and stray variation-selector characters (visibleText), so every rule, the judge and the comment read them as the host shows them. Patches are not changed. The same cleaning reports what it removed, and the facts keep only what that was and where (hidden), for the hidden-content rule. With that rule on, a GitHub repository's pull request templates are read from its default branch too, or its owner's .github repository's when it has none (github_read_pr_templates), so hidden markup that matches a template's own whole comment is not counted as the author's. The portal's pull request details (/api/integrations/github-pull) find referenced GitHub issues in the same cleaned title, body and commit messages; a pasted URL of an issue in the same repository counts too, as GitHub renders it #<number>. With the regression-risk or risk rule on, it also lists each changed file's default-branch commits from the last 30 days (github_list_path_commits, bitbucket_datacenter_list_path_commits, one page each, ten files at a time; tests, docs and lockfiles skipped; at most 300 files per pull request, GitHub's own diff limit) and keeps how many have a subject typed fix, bugfix, hotfix or revert (fixes); a history that fails, runs past one page or is past the 300 is kept as unread, which leaves only those rules unconfirmed. With the focused rule on, it also reads the stack (get_stack): the pull requests merged into the branch, and the open pull request whose branch it is based on. The host lists a pull request's files against its base branch, so one based on another open pull request's branch shows only its own changes and focused runs on them, the comment noting what it is stacked on. A branch other pull requests were merged into holds their changes too, so focused is skipped and the comment says why. With a duplicate rule on, it compares the open pull requests opened before this one on its base branch, drafts left out (list_open_prs with base, one call per base branch); past 100 of them it reads none and both rules are unconfirmed. It reads the first page of each one's changed paths (list_pr_files without patches, ten at a time), so its cost is one call per earlier open pull request. One with the same head commit repeats this one with no further read. For no-duplicate it reads the diff hash (pr_diff_hash, the hash the hub's code review takeover uses, @corbits/common/diff-hash) and commits only of those whose first page lists no path this one does not change, and of this one when any does: two calls each, usually none. An earlier one whose first page can't be read leaves no-duplicate unconfirmed, naming it. For duplicates-open-pull-request it keeps only the shortlist the judge is asked about (overlapping, logic/duplicates.ts): up to five that share a path on that first page, lockfiles left out, or two title words, most shared paths first (openPulls).
  2. rules (manager): runs the hard rules (built-in rules over host data, text rules and code-pattern rules) and, unless one failed or the pull request is a draft or closed, builds the judged rules' questions, or the verdict question when there are none. A code-pattern rule runs its presets' ast-grep rules and its own (its own win on a shared id) with @ast-grep/napi over each changed TypeScript or JavaScript file's head content its files and ignores globs admit (ast-grep's glob rules, checked when the policy is read), and keeps matches on lines the patch adds. The facts step reads every changed file's patch whole, across as many tool pages as it takes, until it has read 2,000,000 characters of the pull request (MAX_CODE_RULES_PATCH_CHARS), each pull request in a batch counting its own, since one sidecar holds every deployment's step outputs; lockfiles and other generated files are listed without their patch, which no rule reads. The file that passes it partway and every later one are listed without patches. The judge reads the same patches (judgedFiles in logic/quality.ts) across up to 64 first-round requests, plus the verdict request and at most 30 focused re-sends; a diff that needs more than 64 in the first round sends the first 64, and the files only later requests hold are unread, so the judged rules that ask about them are Unsure, naming them. A match in any checked file fails the rule. Without one, a file that adds code but has no patch, because the host sent none (GitHub omits it for a very large or binary file) or the pull request passed that ceiling, or whose content was not read, leaves the rule unconfirmed, naming the files and why; a file GitHub counts no added lines in, such as a rename or a mode change, has nothing to check. A rule ast-grep refuses leaves the rule unconfirmed. vendor/, dist/, build/ and generated/ at the repository root and node_modules/ anywhere are skipped and listed as skipped in the evidence, and a ?? 0 inside arithmetic is a counter's real default. With such a rule on, the facts step reads each file whose patch adds lines at the head through the host's read_file (whole), ten at a time and at most 300 per pull request (more leaves the rule unconfirmed without reading any), runs the rules there and outputs only the matches. Bitbucket Data Center lists no patch, so there the rule is unconfirmed whenever an in-scope TypeScript or JavaScript file is added, edited, moved or copied. The extension leaves code-pattern rules out and swaps the native module for a stub.
  3. route (manager): deploys the judge when a question needs asking, and otherwise runs evaluate in the manager.
  4. judge (judge): one System One call per request of questions. Each first-round request and the verdict request also asks whether the pull request's text, diff or docs try to direct the judge; a confident yes in any of them leaves every judged rule unconfirmed and the verdict Unsure, whatever the verdict question answers. The only step that infers; see DECISION_MODEL.md.
  5. parts (judge): a second judge map asks one question per file of each slice that failed focused, and the verdict question over every rule's result.
  6. evaluate (judge, or the manager when route asks nothing): decides the verdict, renders the one comment, and resolves the verdict's steps when the policy's When is woken, skipping each step whose conditions do not hold. While CI is pending every runnable action is held and an approve that may not stand is skipped; the completed check_run (or the reconciler's CI re-read) triages it again. A verdict other than Fail on a head whose facts show the host has still to settle what the policy reads (hostAwaited, as under the manager above) is waiting instead: no verdict, no actions, the waiting marker as its mirror (mark). Rule results never decide it, so a rule Triage could not check for its own reasons, as code patterns in a patch it could not read in full, neither waits nor stops the wait. The hub records no verdict for a waiting one, nor for one on a closed pull request (closed); a head that waits only on CI ends its manager, and the reconciler queues it again once CI settles. A mail with noWait (a manager past its 60-minute CI wait, a head's last attempt, or Triage again) is decided instead of waiting. A CI re-read that queues a head again keeps its attempts, since the head did not fail, and sets noWait on its last.
  7. mirror (manager): writes the verdict label when the pr-triage role holds Labels, and the marker comment when it holds Comment; on Bitbucket it writes the verdict as the Corbits Triage build status on the verdict's own head whatever the role holds, from the host-neutral state verdictStatus (triage-contracts) gives, linked to the comment, or to the pull request without one; each of its writes runs even when another fails. It runs before execute, so a merge or close finds the final verdict on the head. A waiting verdict writes only its waiting marker: the waiting label or pending build status, and, where the role holds Comment, the in-progress comment naming CI. On GitHub the marker passes the fail and unsure verdict labels as keep, so a pull request carrying one keeps its labels until the verdict, which swaps them only when it differs. A head with no checks (facts' checks: "none") gets no waiting label: the marker only takes the pass label off. The portal reads the wait from the live manager's claim (waitingSince), not the label.
  8. review (manager): posts the code review reported for the head through the host's send_code_review tool under the effect id review:<head sha>, after mirror and before execute, so the verdict and the review go up together. A review with no summary and no finding posts nothing.
  9. execute (manager): runs the actions whose kind the pr-triage role holds on the repository. Every other action is held in the verdict (held: true). One the role may write, held only because CI is still running on the head, carries the CI state that holds it (ci: "pending"), which the portal reads as running on its own once CI finishes; any other hold waits for a person. A merge also waits for the pull request's review threads. One function per host, unresolvedThreadsHold (github-reads: GraphQL reviewThreads, neither resolved nor outdated; bitbucket-datacenter-reads: activities, inline root comments neither threadResolved nor anchor.orphaned), says why. It leaves out Triage's own findings, which the code review hold governs: on GitHub a thread opened by a review the App sent with the code review marker (its pullRequestReview.databaseId), on Data Center a comment the token user posted starting with the marker. Facts reads it through the host's review_threads_hold tool when the policy lists a merge, and evaluate skips the merge with waits and that reason, which the comment closes with; the merge write reads it again and refuses as not runnable. Both read every page up to a bound (1,000 threads on GitHub, 2,000 activities on Data Center), and a failed read or one past the bound holds the merge. A skipped merge keeps no effect id, so the pass after the last thread is resolved writes it under the same id. GitHub's pull_request_review_thread (resolved, unresolved) and Data Center's pr:comment:edited whose text is unchanged (a resolve or reopen, checked against Bitbucket 10.5) wake the pull request as threads, which is not a policy event: it re-suggests merge and approve, not labels or every other action, whatever on: lists, since it releases a hold Triage added itself; every other action waits for an event on: lists. A pass that waited for the head's CI or a reviewer acted on none of its events, and the mail that ends the wait names only what ended it. The manager's take therefore answers, as the newest mail's events, those of every pull request mail on the head since its last pass that acted (unacted in manager-state.ts), each read as the trigger reads it, so a mail that names no event (the reconcile pass, Triage again, Review again) is catch-up. A pass acted when it posted a verdict that held no Do for CI (done keeps heldForCi on the closed pass when an action is held with ci: "pending"); a verdict given while CI still runs holds for CI every Do Triage may write on its own, so the pass after CI finishes decides them again under the same events and effect ids. A pass whose verdict a later pass of its run on the head replaced keeps acted beside its heldForCi, so its events wake again only if its own verdict held an action for CI. Events on a head a push superseded are not carried, since effect ids are per head. A pass the reconcile pass queues, after a CI wait or a build status change (on Data Center, where no event reports a finished build), names no event and is catch-up, which wakes every action.

The hub reads which kinds the pr-triage system role is granted (invoke on triage-do:<kind>:<source>:<owner>/<name>), after the grant writer has followed that policy read (see Tenancy and authority), and mails them with each trigger; the run trusts them only from the hub's sender. The kinds are acted on later than they are read, so a condition checked at read time would not hold at action time: a conditioned allow does not count and a conditioned deny always applies. Close on Unsure, and merge on anything but Pass, are always held. The hub mails the policy twice: pack as resolved from the repository's policy file and any policy file it extends, and checkPack in the shape a deployment built before steps reads.

A person holding the same grant approves a held action, or one the run failed to write, from Triage: the hub mails the deployment the action, its effect id, head and approver. That mail skips triage: an approval action step reads it, trusting only the hub's sender, and execute writes the action unless the host already shows it; a close, merge, comment or review is refused once the head has moved. The hub reads the approval run's outcome back onto the verdict that held it.

"Always approve for this repository" (named after the kind) on a held action runs it as above, then calls POST /api/integrations/triage-automation/:tenantId with { repo, source, kind, list } (apps/hub/src/github/triage-automation.ts), list being the held action's verdict. The route checks the caller's stock create on grant:*, refuses a kind the host cannot write (hostKinds), one alwaysHeld on list, and one the repository's policy file does not want (policyKinds of its current read, the policy read's wanted), and, reading the tenant row under a share lock so a repository switched off meanwhile loses the grant with its others, requires an enabled record of that source. It builds triage-do:<kind>:<source>:<repo> itself and gives it to the pr-triage role with allowRole, so the portal never names a role or a grant resource, and answers the repository's automatic kinds. An admin's triage-do:* and a person's triage-do:<kind>:* name no host, so each still matches the same kind on either host. Other held actions of that kind stay as recorded; the next pass of each pull request's manager is mailed the kind as automatic and runs it. Settings lists those kinds per repository and, only while corbitsTriage.automation is ask, removes one through the stock grant delete route by grant id; otherwise the next policy read would grant it again.

Every step but the judge is a deterministic action. Facts, mirror, review and execute reach the pull request's host through its credential, bound to each of those action handlers (credentialBindings with action: <handler>): each declares the host's handle in credentials and a capability in effect.requires (pull-request.read for facts, pull-request.write for mirror, review and execute), and calls the host only inside ctx.perform's run. Facts is one effect; each mirror write and each Do is its own effect under its call id, the Do's effect id, so a resumed run does not write it again. The calls are the host tool's (githubCalls, bitbucketCalls), so a host operation has one implementation.

Code review pipeline

A repository's policy file turns code review on with its code-review block, and Review again asks for one whatever the file says; both need a code review provider's key. A workspace runs one provider, the one whose key it stores in Settings → Code review (CODE_REVIEW_PROVIDERS in triage-contracts, which lists Anthropic). The manager deploys the code review workflow in a sandbox and posts its result. The hub attaches the review request to a pull request mail whose codeReview is on, which the policy file's code-review block or Review again sets, with the host's whole unified diff; on GitHub a diff too large for the diff API is built from the files' patches. The title and description are read as the facts step reads them (shownBody and visibleText in @corbits/triage-contracts), so hidden text never reaches the review. …/run (Review again) records a pull request mail for the current head with code review on for everyone, on any verdict and whatever the rules say, and names it with an again id. The manager deploys one more review of a head for each again it has not reviewed, even where an earlier review of the head settled. Code review only reads: a checkout action downloads the head through the host credential to <SIDECAR_DATA_DIR>/checkouts/<host>/<owner>/<repo>/<sha> and writes the whole diff beside it in <sha>.diff, a folder made afresh outside the tree the pull request controls (a link or file in the way fails the action). Repository reads are confined to the checkout. Checkout still writes per-file patch parts of at most 1,500,000 bytes and /diff/index.jsonl, but the finder does not page them. Code builds one finder message per nondeleted changed file, splitting only when needed to fit 120,000 UTF-8 bytes for the fully escaped message, including its title, description, repository and budget tags and <diff path="..."> patch block. Splits prefer line ends and preserve UTF-8 characters. Deleted files receive no bundle; small files are not combined. The policy's path conditions still gate the whole review, not individual bundles.

The narrow pipeline is findersInput → find → candidates → checks → check → review. There is no plan, repository finder, consolidate or model write step. A single finder runs sequentially over the bundles, with a 400,000-token budget per bundle. Its read_file, grep and list tools are for repository context outside the supplied patch, not for reading /diff. Candidates are folded deterministically by matching path, start and end and normalized title (or explanation and compatible code when untitled), retaining the first finding's words and anchors.

Completion means every assigned bundle returned through the successful finder map, not that tool reads covered every patch line. An empty assignment is complete. A missing or failed bundle leaves the review unfinished; it is not a clean review. New successful results set complete: true, unread: [] and unreadFiles: 0. Budget exhaustion is independent of completion: a returned bundle can exhaust its reading budget, and budgetExhausted still holds approve and merge and displays the stopped-early warning. Stored reviews with complete absent or false retain the existing INCOMPLETE_NOTE, unread-file information and approve/merge hold.

The existing checker verifies each kept candidate: it gets the finding's path, lines, title, explanation and trigger, the diff hunks at those lines, and repository read tools. Check budgets are handed out up front in severity order: budgets.check defaults to 100,000 billable tokens per finding and budgets.checkTotal to 400,000. Later findings are unsure with the reason "check budget spent". The checker answers real, not real or unsure; the reader director downgrades a real or not-real answer that cites no line from its hunks or reads to unsure ("no cited line"). After a budget-refused read, only a not-real answer citing such a line stands; other answers are unsure. An invalid answer otherwise fails the step and is retried.

Only real findings survive. Not-real and unsure findings stay in dropped with their verdict and reason; repeated reports do not substitute for confirmation. The deterministic review action uses the finder's original title, explanation and trigger, with title fallbacks, and preserves its path, start and end. No model rewrites or re-anchors the finding. With no survivors the summary is No defects reported.; otherwise it is empty and the findings carry the review. The manager remains the sole pull-request writer.

Beside the model steps, one step runs a tool and keeps what it says, as structured findings (source, rule, path, line, message, severity, fix when the tool gives one) and are not posted. lint runs the native oxlint binary every image that runs sidecars pins (never through bun or node) over the changed files with the repository's own .oxlintrc.json, nested configs off, and keeps hits on added lines. Only data keys pass (rules, categories, built-in plugins, env, globals, settings, ignorePatterns, overrides of the same); a config with any other key (extends, jsPlugins, options among them) or one that is code (oxlint.config.*) is refused unrun. The kept keys are written to a config in a fresh folder outside the checkout, oxlint runs from that folder with only an absolute PATH, ignore patterns are applied before it runs, and the absolute file paths follow --. A changed path missing from the checkout makes the step incomplete. A repository with no config is not linted. A lint that fails is incomplete, never an empty list.

Each request carries diffHash, a SHA-256 of the full diff, index lines included, so a base change to a file the diff touches, or a binary change, changes it. A diff with no index line, as GitHub's rebuilt from the file list, is sent unhashed. A manager's code review reports the hash with its review. GET …/code-review with head answers carriedTo when that head's diff hashes the same as the newest finished review's, reading that head's diff once; a failed read is not retried for five minutes. The review is then sent to GitHub at that head, unless it was sent on its own, whose comments stay on the same lines. A diff that can't be read and …/run review the head instead.

Bitbucket Data Center anchors a comment to one line, so its send (POST …/bitbucket-datacenter/{tenantId}/code-review) posts one comment per finding the effective diff shows, on its first shown line, then a summary comment with the rest. Each comment's first line is a link-reference marker naming the head (and the finding's index), so a send that failed part-way posts only what is missing, and none once the summary stands on either head.

A pull request's latest review (GET …/code-review, code-review-runs.ts) is read from manager_state: the newest of the code reviews its claim still waits on, shown as running from the reviewer's own record until it reports or its deadline passes, whether or not a manager is up, those the manager gave up on, such as one past its deadline, and the results its code reviews reported through the reviewer-result route that a pass took or that name a reviewer of the claim. A review that did not finish shows as failed; its run log keeps the error.

The manager posts each review with its verdict (step 8 of the triage pipeline). A person sends a finished review the manager has not posted through POST /api/integrations/github-actions/{tenantId} (action: "code-review", the Comment grant): the hub reads the newest finished review the same way and posts it as one COMMENT review pinned to its head. The review's marker and commit_id are its effect id, so the App's marked review of that head is found before anything is written.

The manager also records what became of each finding it posted inline. Its send returns, per finding, the host comment that holds it (on GitHub read back from the pull request's review comments of that review, each matched by its path, the lines it was written on, which lie in the finding's, and the exact text written for the finding on those lines; on Bitbucket Data Center the id each inline post returns), and done keeps that id as thread on the finding in its reviewer-result row; ids that do not line up with the review's findings are logged and left out. A GitHub read-back that fails, or that matches fewer comments to findings than the review holds inline, leaves the ids unknown and is logged as finding_threads_unread; the review counts as posted, and a later send of it reads them again. Take no longer names thread ids only on the first pass of a newer head. A pass whose webhook triages as threads also names this head's inline finding ids, numeric only, those not stored resolved first and then those stored resolved, then other heads' ids only on the first pass, still at most 100, an id past that left out. A later pass that does not triage as threads names none. It reads the named ids in one findingThreads step through the host read tool (finding_thread_states: GitHub's GraphQL reviewThreads, Data Center's comment by id), and done writes each state onto the finding in the reviewer-result row: resolved, outdated, replied (by someone other than Triage) or untouched, with at only when the host names the time of the change (a reply's time on both hosts, a resolve's on Data Center). Host thread state is the source. A read the host refuses is logged as finding_threads_unread and leaves that finding holding, and does not leave that finding stored resolved, so a later pass cannot treat the failed read as resolved; on Data Center, where each comment is its own read, only the comments that failed stay unread. On Data Center the fields read (threadResolved, threadResolvedDate, anchor.orphaned, nested comments) were checked against Bitbucket 10.5. The portal shows it from GET …/code-review; a finding held in the summary, one a person sent from the portal that the manager never posts again on that head, or one posted before Triage kept its comment is not-tracked. Each finding's open follows that record through findingOpen in triage-contracts: a finding whose thread is resolved or outdated is no longer open in the portal; replied, untouched and not-tracked ones are. The approve and merge hold does not follow findingOpen: only resolved releases the hold on that same commit, with no new push; outdated, replied, untouched, a finding with no comment id, and a thread this pass could not read stay open. A comment id kept on the code-review event counts when the claim's reviewer result has none, matched to that finding: the pass that asked for it releases only on a read of resolved, and a refused or omitted read still holds. A later pass that did not read this head trusts a stored resolved that a successful read wrote, on the claim or, when the claim has no id, on that event. With at, the route answers only that head's reviews and carries none over to it, so an older head's review reads with the states recorded for it. A closed or merged pull request is not read again.

The steps, the provider profile and how to add a provider are in packages/pr-code-review/README.md; the eval and its numbers in packages/pr-code-review-anthropic/evals/README.md.

Policy blocks

A policy file that turns a block on keeps its v4 keys in the policy the hub reads, and its pull request mails carry codeReview, blocks and decide. The manager runs code review behind rulesGate in triage-contracts: under rules: pass, the hub's default unless the block's when.rules is any, the review stays shut unless every policy rule in the verdict's checks read without a model whose failure fails the pull request passed (hardFailures, shared with triage), CI only through ci-green and never the built-in not-a-draft or no-changes-requested. The manager does not run decide; it runs the policy's actions. Code review's findings never change the verdict. The manager's cleared step (packages/triage-manager/src/actions/review-clean.ts) reads each Do's step in the pack and holds the Do for a person when the head's review does not meet the step's when.findings: { above: P<n> } allows only findings less severe than P, { any: false } none and { any: true } any. A step without one is held only when it approves or merges and the review has any finding open. The approve and merge hold does not follow findingOpen: only resolved releases the hold on that same commit, with no new push; outdated, replied, untouched, a finding with no comment id, and a thread this pass could not read stay open, and a refused read does not leave that finding stored resolved. A comment id kept only on the code-review event is that finding's id for this check. A later pass that did not read this head trusts a stored resolved that a successful read wrote, on the claim or, when the claim has no id, on that event. A review that did not run, failed, ran past its deadline, read only part of the change or reviewed another head meets no condition. Each Do it holds adds one line to the verdict's comment naming the action and why, and a held approve makes the head not approvable, so Triage takes back an earlier approval. A pull request the block's for or when leaves out runs its Dos as if code review were off.

Tenancy and authority

A signed-in user whose address is not verified reaches no hub route until it is: the hub's front door (apps/hub/src/verified-gate.ts) answers every /api/ request from such a session with 403 email_unverified, before the custom and stock routes. Open to it: better-auth under /api/auth/ (session, sign-out, send-verification-email, verify-email, magic links) and /api/integrations/auth-methods. Webhooks under /api/hooks/ are routed before the gate; sidecar sockets and git and agent-state tokens carry no user session, so it lets them through. The hub's first account (the oldest user) is exempt unless AUTH_ALLOWED_EMAILS is set, so a hub without email can be set up. Without email, that first account vouches for the rest: the invite route lets an admin invite an unverified email and password account, and /api/integrations/invitations/{tenantId}/{principalId}/verify sets emailVerified for a member or invitee of the caller's workspace, under the invite grant, logged as email_marked_verified with both principals. It refuses anyone but the first account (apps/hub/src/hub-owner.ts): verified holds hub-wide, and any verified account can create a workspace and bring any account into it through stock invites, so a workspace admin's vouch would be too weak. A hub that sends email refuses it and leaves the proof to the emailed link. Resending that link is limited to once a minute per signed-in account, or per address without a session, rather than by better-auth's per-IP limit alone, since a caller can change its address. Stock has no unverified state of its own: tenant creation gives any signed-in user an active owner membership, and the stock tenant middleware refuses only inactive memberships, so it covers neither /api/me nor tenant creation.

better-auth's sign-in and email rate limits key on the caller's address. At start-up clientIpHeaders in apps/hub/src/env.ts detects the host from a variable the platform sets and reads the address from the header that platform's proxy sets: X-Real-IP on Railway, detected by RAILWAY_ENVIRONMENT_ID. On a detected host each session also records that address. Anywhere else better-auth reads X-Forwarded-For and trusts it only when it holds one address; otherwise every caller shares one bucket per path, so the hub logs auth_client_ip_unresolved once at start-up.

A workspace is an Interchange tenant; repositories are tenant config (corbitsTriage.repos) plus a triage state artifact each, and each one's policy is its own policy file. Runs act under the grants materialized for their deployment.

A model step gets only read tools confined to the checkout or the pull request's head (repo-read-tool's read_file, grep and list, and the host's *_read_file), never a shell, network, write or push tool, and tooling/model-step-tools.test.ts fails bun run test otherwise.

Each workflow binds at most one credential per host. On GitHub the manager binds github (provider github, stock http), an installation token, never the App key, to its host actions (facts, mirror, execute, approvals and posting a review); it is the only deployed workflow that can write to GitHub. The listener and the judge bind no host credential, only the hub's. Code review, which runs a model over the pull request's code, binds only github-read (same provider): a second token for the same installation the hub mints with contents: read and the workspace's repositories from that installation that GitHub still lists in it, so a renamed or transferred repository is left out rather than failing the token. Past GitHub's limit of 500 repositories it asks for no list and reads every repository of the installation, still read-only. It refuses to keep a token GitHub answers with any other permission, a repository it did not ask for, or every repository when it asked for some, so no code review deployment holds a token that can write or reach past the workspace. With none of the workspace's repositories left, github-read is marked expired and its token blanked. It is minted, stored, pushed and marked expired with github, in the same transaction under the same lock, and minted again when installation sync or an installation webhook changes the repositories. A refused read token leaves github renewed, keeps its reason as tokenError on github-read, and code review is not deployed until a good one is minted. Bitbucket Data Center has no token the hub can narrow, so its manager and its code review both bind the workspace's one bitbucket-datacenter token. The vault holds the App as {"appId","privateKey"} under github-app, which no workflow binds, and its webhook secret under github-hook. The manifest callback and PUT /api/integrations/github-app/:tenantId write the key and the token minted from it in one transaction; App JSON a stock credential write leaves in github is moved to github-app when the hub next reads the key: the next portal read, installation sync or token loop pass, so at most 45 minutes later; a deployment launched from that credential in between is handed the key. The hub mints the token on boot and renews it every 45 minutes. Every write holds the tenant's advisory lock, and a renewal stores its token only while the key it minted from is still the workspace's, so a replaced App is never overwritten with the old one's token. Each renewal then calls stock pushCredentialReconcile for every running deployment holding the credential, since renewal writes straight to Postgres rather than through the stock credential route. One token covers one installation: the workspace uses the one its repositories came from, else the oldest; installation sync and the installation webhook leave out other accounts' repositories. A github-app key and github-read token whose github credential was deleted are dropped by the renewal loop. A failed renewal keeps its reason as tokenError on the credential's metadata, shown on the portal's GitHub App row; once the token is past its expiry the credential is marked expired, which stock launch resolution skips, so no deployment launches with it. The next good renewal sets it active again; running deployments still re-resolve an expired credential, as stock drops only revoked ones. Hub routes that reach GitHub for the portal sign with the App key on the hub. Each GitHub mail names the App's bot account (<slug>[bot], the slug GitHub answers for GET /app, refreshed on every renewal) as appLogin, and only that account's comments and reviews are triage's own. Reads without a login (the policy check Action, the extension and the local CLI check) take a marked review from any App account as triage's own, so another App on the repository could mark its review that way. Routes that read the host check the caller's read (or use) grant on github and never return a secret; routes that write check use. A pull request action of a kind triage can run also checks invoke on triage-do:<kind>:<source>:<owner>/<name> (the repository name in lowercase). An admin's triage-do:* and a person's triage-do:<kind>:* name no host, so each still matches the same kind on either host.

Every connect of either host (connectTriage in apps/hub/src/github/automation.ts, run by GitHub connect, manifest completion and Bitbucket Data Center connect) creates the pr-triage system role when it is missing and gives the admin role use on the host's credentials and invoke on triage-do:*, again on each reconnect; owners hold every grant. Nothing else creates the role, so a workspace without it has no automatic kinds. Stock's grant table has no unique index to insert against, so the hub writes every grant only after looking for it under a transaction advisory lock on its holder (lockHolder, apps/hub/src/system-writes/grants.ts): writing one twice leaves one row.

What the role holds on a repository is decided by the policy file and the workspace's corbitsTriage.automation, policy or ask, never by a list in the hub. The namespace's first write stamps policy, so a new workspace starts there; on boot, keepExistingWorkspacesAsking (tenant-config.ts) writes ask once into every namespace written before the setting existed, so those workspaces keep what was allowed by hand and nothing more until an owner switches:

  • Enable. Switching a repository on grants the role its posting kinds (postingKinds: comment, and labels where the host has labels) in the transaction that writes the record, through the tenant update on GitHub and the enable route on Bitbucket Data Center, in either mode. Both then wake the reconcile pass, whose policy read grants the rest under policy.
  • Grant writer, under policy. Every successful policy read of an enabled repository, on both hosts and whoever asked for it (a webhook, the reconciler, a backlog, Review again, the portal's policy route), is followed by followPolicy (apps/hub/src/github/policy-grants.ts, called from readCheckPack in apps/hub/src/server.ts). In one transaction it takes a share lock on the tenant row, which every writer of the corbitsTriage namespace locks for update, and goes on only while that repository is still enabled on that host and the mode is still policy, so a repository switched off meanwhile is not granted again. grantWanted (automation.ts) then takes the role's holder lock and makes the role hold exactly the kinds the host can write among policyKinds (every kind the verdict steps and decide rules name, never a step alwaysHeld on its verdict, whose grant would allow nothing) and posting: it inserts only the missing allows on triage-do:<kind>:<source>:<repo> and deletes the role's allows there for every other kind, so the same policy read again touches no row, and a kind taken out of the file, or one the host lacks (Labels and Assign on Bitbucket), is taken back. Under that lock it first checks the read is still the newest of the file (isNewest in policy-file.ts); one a newer read overtook writes nothing, so it cannot undo the newer grants. The read carries the kinds as granted. A missing or invalid read changes nothing. Switching the workspace from ask to policy writes nothing itself; the next reconcile pass reads every enabled repository's policy and grants.
  • Under ask the hub writes no grant but posting on enable, and switching from policy to ask through the tenant update takes back every kind but posting on each enabled repository in the same write (prepareCorbitsTriagePatch). A policy pull request the hub opens lists the kinds its file uses, posting aside, that the role does not hold, which will wait for a person.
  • Switch off or remove. Every write of the namespace (patchCorbitsTriage in tenant-config.ts) that switches a repository off or removes it deletes the role's triage-do grants for that host and repository in the same transaction. A grant names the host, so the other host's record of the same name keeps its grants. Enabling it again starts from posting.

A member given the pr-triage role is refused every action until it is removed, so what triage does unattended never becomes what a person may do, and an invite into the pr-triage or Deployer role is refused. Approving a held action checks only its kind's grant.

A deployment acts with the grants its source asset's creator holds, so the hub's publisher needs every grant the listener's chain declares from its creator. The hub keeps a system Deployer role per workspace (apps/hub/src/deployer-role.ts), ensured only by the publisher before each roll, which gives it to the workspace's triage-publisher system principal. It holds exactly DEPLOYER_GRANTS (@corbits/triage-contracts), and any other grant on it is dropped: create on workflow:*, manage and read on workflow-run:*, write and deployed on triage-manager:state and create on triage-manager:result. Stock gives an asset's creator no grant on it, so the publisher also holds read, granted as its creator, on the asset of each workflow another one deploys: the manager, the judge and the code review, not the listener.

The publisher's setup, its Deployer assignment and the workspace's lifetime ceiling, goes through the hub's write layer (apps/hub/src/system-writes) in one transaction. So do the publisher's creator read on each asset another workflow deploys, written once however often a roll publishes, and taking the Deployer role from people, one transaction for all of them. The layer writes as the workspace's triage-hub system principal: its first write in a workspace mints it through stock's principal store and gives it stock's system admin role; a workspace without that role is refused. It is an agent principal, so its address never resolves as a mail sender. A transaction declares every write it will make up front, as what stock's own route checks (manage on tenant:<id> for the ceiling, manage on role:* to assign or take a role, create on grant:* for a creator's grant, and for the Deployer role create on role:* and create on grant:*), and the layer runs stock's authorize for each, throwing SystemWriteDenied unless it allows, so taking admin from triage-hub stops those writes in that workspace; a write the transaction did not declare throws. The Deployer role also checks manage on each grant:<id> it deletes, as stock's route does, once the transaction has found them. Every check runs inside the transaction, through stock's grant store bound to it, after the transaction holds triage-hub's admin row, so admin taken away before the writes, or a grant taken away before the checks, still stops them. Concurrent first writes to a workspace share one mint of triage-hub. A role is assigned only to a principal of the same workspace, and only a role of that workspace.

Where triage mirrors and its policy approves, a settled head that may not be approved dismisses the approvals the approve action made; one dismissed on a head is not made again on it. A person's own approval from Triage is never dismissed; an approve action a person lets through from Triage is.

The portal saves repository settings through the stock tenant update (PATCH /api/tenants/:id), which the hub checks first. Besides tenant manage, it takes only the workspace's automation (policy or ask), a repository's enabled, and dropping a repository. Switching a GitHub repository on needs use on the App credential; switching a Bitbucket one, or dropping it while on, is refused (409 use_bitbucket_route), since its own route also manages the webhook. Every other key keeps its stored value. The hub writes corbitsTriage itself under the tenant row's lock, refusing 409 if it changed since the hub read it, and passes the rest of the request to stock.

Data

  • Interchange's own tables, artifacts and run logs hold tenants, credentials, deployments and runs.
  • Artifacts: the triage state (triage-state/{owner}/{repo}) per repository, which the hub writes itself. It holds each open head's status (new, queued, running, triaged, failed, stale) and attempts. No policy is stored (see Policy file above); the trigger mail still names policy.checkPack.name for deployments that read it.
  • A policy file whose extends is a repository name is resolved over that repository's policy file (resolvePolicy), read through the same per-repository cache, at the time the policy is read: built-in rules merge field by field, custom rules by id (at most eight together), and when, each verdict's steps and each verdict's label come from the extending file only when it sets them. Each trigger mail names the versions its pack was resolved from under policy.source; policy.source.sharedFile names the extended file and its blob. A push or merge to the shared repository refreshes its cache entry, so no dependent is tracked. A Bitbucket repository that is not enabled has no triage webhook, so its file is kept for a minute instead.
  • Every migration runs in bun run db:migrate (apps/hub/src/migrate.ts) as its own step before the hub starts: bun run dev, Compose's migrate service, or whatever the operator runs as a pre-deploy step. It applies Interchange's tables with stock drizzle-kit migrate, then the @corbits/webhooks, @corbits/artifacts and @corbits/cron tables, triage's tables and the data backfills, and records the last apps/hub/migrations file it applied as the corbits_triage schema's comment. The hub refuses to start, naming what is missing, while fewer Interchange migrations are applied than the vendored journal lists or the recorded file is older than its own last one; the @corbits/* packages' migrations keep no record of their own.
  • Triage's tables, in the corbits_triage schema: verdict (verdicts per head, with the outcomes of the actions their run executed, as a manager's done reports them; a manager run's newest verdict on a pull request replaces the one it recorded before, keeping the outcomes of Dos already written on its head. The verdict's startedAt is when the run first took its head, and its trigger the newest mail for it. A verdict's host is its run's workflow's, by the workflow id its deployment's frozen projection keeps, and GitHub when that run is gone), device_code (better-auth's device authorization, for agents a signed-in person approves) and manager_state (below), migrated from apps/hub/migrations by db:migrate in one transaction under an advisory lock. Nothing else is stored: GitHub App setup state is a signed token, and an invite is a stock invited principal.
  • manager_state (apps/hub/src/manager-state.ts) holds a pull request's manager claim (mgr:<source>:<repo>#<n>: status, the run it names, the open pass, reviewers and closed passes, each closed pass with whether its head waited for CI, and the newest pass of a run on a head with the mirror request of the verdict it posted) and its pending events (evt:<source>:<repo>#<n>:<deliveryId>), each change one short transaction. Recording an event is a no-op on a redelivery; the database stamps receivedAt to the microsecond as it inserts the row, so events sort in the order they were recorded. A held Do's approval names the pass's head only when no mail or earlier pass does, so approvals alone on a fresh claim are a pass; a reviewer result alone before any pass has no head and stays pending, and done waits only on events take can use. Take claims the pull request for the calling run when no live run holds it: the claim is missing or ended, or the run it names has gone, whose open pass the caller resumes. Concurrent takes queue on the claim's row lock, so one claims it and every other is refused and stops itself. Take and done refuse any run the claim does not name. Take resumes the open pass or takes every pending event no other transaction holds, keeps the newest head and consumes reviewer results for other heads; reviewer results that arrive before the first pass stay pending. Either way it answers the reviewers the last done recorded and the newest pull request mail recorded: the pass's own, or for a pass without one the newest pr mail. Done closes the pass and, under the claim's lock, ends the claim when no event is pending and the pass did not ask to wait, so an event committed before it is taken by this manager and one after it by the next. A manager run reaches take and done, and a reviewer run records its result (under its own run id), and a listener names the manager it deployed, through POST /api/tenants/:tenantId/manager/{take,done,reviewer-result,deployed} (apps/hub/src/manager-routes.ts). They admit a run token only, as the hub's extra run-token routes. Take and done need write on triage-manager:state; reviewer-result needs create on triage-manager:result and nothing more; deployed needs deployed on triage-manager:state and nothing more. The calling run is the token's run, never a body field. Take and done answer 409 not_manager to a run the claim does not name. Done's body carries the pass's report and approvals: the hub keeps the report on the closed pass (a pass that reported without taking events, as one past a reviewer's deadline, is closed as its own), and in the same transaction records a posted verdict and sets each approved Do's outcome on the verdicts that suggest it. Reviewer-result is not fenced: any run with the grant records under its own run id, and the manager matches results to its reviewers by run id. Its body is a StageResult (@corbits/triage-contracts): the reviewer's kind (code-review or judge), the head it reviewed and its result, a code review's output or a judge's whole verdict, or failed with why it has none. The result is recorded by one statement only while its head is the newest one a pull request mail named, so no push slips between the check and the record; one for another head is dropped and logged as reviewer_result_stale. Otherwise, once recorded, the route wakes the pull request's manager as intake does for a host event (createManagerWake, apps/hub/src/manager-intake.ts): the live manager the claim names is mailed, or the host's listener is mailed the head to deploy one; a workspace with no listener logs reviewer_result_unrouted. A wake that fails leaves the result recorded and nothing else, is logged as reviewer_result_wake_failed, and answers 502 wake_failed; the next reconcile pass wakes a manager for it. Done records the reviewers the manager reports, then the hub stops each the claim holds on a head older than the newest, as one the pass deployed while a push arrived, the way intake does below.
  • Pull request, held-Do approval and backlog mail goes to the host's listener (apps/hub/src/manager-intake.ts), on both hosts. Each pull request the mail names (a reconciler's items, a backlog's open pull requests) passes one gate first, whatever sent the mail: on GitHub, a pull request carrying the policy's pause label is dropped. The hub records one webhook event per pull request that passed, with the head's code review request when the policy turns code review on and the App's login on GitHub, then mails the listener the heads. Once it records a head, every judge or code review the claim still waits on for an older head of that pull request is stopped through the stock run lifecycle with the hub's own authority and then taken off the claim, each logged once as reviewer_superseded. A stop that fails is logged as reviewer_supersede_failed and leaves the reviewer on the claim; each triage reconcile pass stops the superseded reviewers of every claim again (createSupersede). When the host's head moved past the mail's, the event goes without a review request and the hub logs code_review_head_moved; the new head's own mail carries one. An event's id is the webhook's delivery id, which the coalescer's mail carries, so a redelivery records nothing new; any other mail is a new event, so a head mailed again, as after its base moved or its CI settled, is triaged again. A workspace with no live listener for the host mails nothing: Triage again, Review again, a held Do's approval and backlog triage answer 503 service_not_running, and a webhook's head fails, each saying Triage is not running on that host and that Settings → Triage shows why. While the host's listener is starting or being replaced, Review again answers the same 503; the others are held as above.
  • Webhook replay protection uses the @corbits/webhooks table and a delivery-id cache in HUB_DATA_DIR.

Hub observability

The hub configures stock @intx/log / LogTape once at the application entry point. logJson remains the structured application logging convention and preserves JSON console fields. Local logs are not redacted by the external-event projection: access to stdout must remain restricted. Stock hub logs retain debug-level ANSI console output outside NODE_ENV=production, and info-level JSON lines in production. Application logJson output stays JSON in both modes. The migration CLI also configures logging before migrating and disposes it on success or failure. The hub factory never calls stock setup() afterwards, since that would reset the sinks. Sidecar logging is independent and unchanged.

At /manager/reviewer-result, only after the atomic result-recording statement has committed, the hub logs one of reviewer_result_accepted_success, reviewer_result_accepted_failure, reviewer_result_duplicate, or reviewer_result_stale. Success here means the reviewer submitted a result, not that the pull request passed policy or that code review was complete. Stale results do not wake a manager. Other results retain the existing wake behavior, including retrying wake on a duplicate: reviewer_result_woken, reviewer_result_unrouted (no listener), or reviewer_result_wake_failed (the existing 502 response; the recorded result remains stored). Woken means the wake operation returned successfully, not that a manager consumed the result. These are observations, not approval decisions.

External delivery accepts only those named events from the application logger category, not arbitrary stock logs, raw message templates or exceptions. The envelope explicitly projects msg, tenantId, source, repo, number, head, runId, kind and optional managerRunId, with runtime validation and bounded string sizes. Tenant and reviewer run IDs come from authenticated route context. Repository/head/kind come from the validated result request; they are not independently verified host facts. The manager ID is the claim's run ID from the same SQL statement's snapshot, not a newly deployed or inferred association; absent/ended claims omit it. This does not claim the manager deployed that reviewer. Raw results, prompts, source, credentials, mail, exception details and arbitrary extra properties are never projected. Correlation identifiers, including private repository names, are still sensitive metadata; destinations must be authorized to receive them. Both hub entry points explicitly supply empty telemetry, errors and notifications arrays: no external providers are enabled. Composition must supply all three arrays (an empty array deliberately disables that role); omitted roles are configuration errors, never silently replaced with empty arrays. Logging mode is also required at configuration time and selected by each entry point from its validated environment.

Injected destination arrays have distinct roles:

  • Telemetry: all projected review outcomes and wake diagnostics.
  • Errors: accepted reviewer failures and wake failures.
  • Notifications: those errors plus missing-listener wake results. Duplicates and stale results are not new alerts.

Each destination has an independent in-memory queue: at most 64 events including its one active send. Enqueue does not await delivery or invoke provider code on the domain call stack. Saturation drops the newest event and logs one local overflow diagnostic per destination for the process. Synchronous throws and rejected promises are caught without exporting the exception, and other destinations continue. A send exceeding two seconds aborts its signal, drops its queue and disables that destination for the process lifetime. Even if it ignores abort forever, it gets no further sends; timing out never creates an unbounded collection of in-flight promises. Destinations must use asynchronous I/O; this cannot isolate a CPU-blocking callback. Local delivery diagnostics contain only a fixed reason and destination array index and bypass external sinks to prevent recursion.

LogTape's asynchronous sink disposal closes intake and drains for at most three seconds, then aborts active sends and discards queues. The server awaits this disposal during shutdown. This bounds observability disposal, not unrelated sidecar shutdown. The limit values are injectable for unit tests, not production environment settings. Process crashes, forced exits, saturation, provider failures and shutdown deadlines can lose events. There are no persisted deliveries, retries, recovery alerts or exactly-once guarantees; the SQL result and external event are not a transactional outbox. Existing SMTP liveness alerts and workflow mail dispatch are separate mechanisms, not repurposed as generic outboxes.

Follow-ups remain explicit: preserve safe failure classifications/run references through the failed-review API and UI; independently detect overdue/unconsumed results; persist incident/delivery state with retry/dedup/recovery behavior; implement authorized provider adapters; instrument sidecar startup; and add an external heartbeat/watchdog for a dead hub. These logs do not establish the initiating cause of earlier production incidents.

Vendoring

Interchange is vendored under vendor/interchange at a stock pin plus the deltas in vendor/interchange/VENDORED.md, each with the condition for removing it. tooling/vendor-diff-check.sh fails on any other drift, including a stock file missing from the vendored copy unless tooling/vendor-diff-pruned.txt lists it or a directory above it as an upstream path we don't vendor.