Skip to content

Latest commit

 

History

History
150 lines (94 loc) · 31.1 KB

File metadata and controls

150 lines (94 loc) · 31.1 KB

ADR: GitHub integration — a GitHub App for private-repo deploys and zero-config push-to-deploy

Status: accepted — 2026-07-11; amended 2026-08-21 against the shipped ADR078 model (w5/m74 multi-installation + w5/m75 claim flow) and the live production walks that falsified several original claims: the connection model is N installations per workspace (not one), the callback is the App's OAuth Redirect URI (not a "Setup URL" — GitHub disables the Setup URL when "Request user authorization during installation" is on), GitHub preserves the signed state only for first installs (already-installed accounts bind via ADR078 §3a's claim flow), and a missing OAuth pair now refuses at connect start (ADR078 §7). Superseded statements below are corrected in place; §8 records the Render-parity status and remaining plan. Implements the connect+list half in w2/m8 and the private-clone + hands-free push-to-deploy half in w2/m9. Optional feature: unset config ⇒ 503 on every git-connect verb, exactly as ADR013-secrets.md/ADR003-control-plane.md gate their features.

Context

docs/ADR008-vision.md pillars 3–4 want "which of my repos can you deploy?" to be one agent call, and "a later git push redeploys" to be true for private repos with no per-repo setup. Two things were missing:

  1. A managed connection to a git host, so bex can enumerate a workspace's repos and clone private ones — today ADR017-deploy-from-chat.md can only build a public spec.repo and only redeploys via a manually configured per-repo webhook holding the shared BEX_WEBHOOK_SECRET.
  2. A credential the in-cluster build (w1/m5) can use to clone a private repo, minted without teaching the operator anything about GitHub.

The parity target is Render's model (render.com/docs/github): install the Render GitHub App with an all-repos or selected-repos grant → the granted repos appear in the create flow → private clones just work → the app delivers signed push events for every installed repo, so auto-deploy needs no per-repo webhook.

The rule this works within (from ADR007-restart-suspend-and-resume.md, ADR003-control-plane.md): product concerns live in bex-api; the operator is DB-free mechanism. bex-api owns GitHub; the operator only consumes an opaque k8s Secret.

Decision

1. A GitHub App — not an OAuth app, not a pasted PAT

bex integrates GitHub through a GitHub App. The app gives us exactly the three primitives the parity target needs:

  • Short-lived installation tokens (1h) minted on demand from the app's private key — never a long-lived stored credential.
  • Per-repo grants managed on GitHub's side (the install screen), so the workspace admin, not bex, decides which repos are exposed.
  • One app-wide webhook configured once at app-creation time that delivers signed push events for every installed repo — the foundation of zero-config push-to-deploy.

A personal access token was rejected: it is a long-lived secret bex would have to store and rotate, it grants all of a user's repos with no per-repo scoping, and it carries no app-level webhook (push-to-deploy would stay manual). A plain OAuth app was rejected too: OAuth apps authorize as a user (again broad, user-scoped, revoked when the user leaves) and have no installation model or per-installation webhook — they answer "log in with GitHub," not "grant these repos to this workspace." (GitHub social login is a separate feature, w4/003; it can reuse the same app's client id — see §6.)

2. Self-hosters mint their own app via the manifest flow

bex is open-source and self-hosted, so there is no single "bex GitHub App" — each operator creates their own. GitHub's app-manifest flow makes this one click: the operator opens a bex-provided form that POSTs a JSON manifest (name, permissions contents:write + metadata:read, the push webhook event, the callback/redirect + webhook URLs, and request_oauth_on_install: true — no setup URL; see the required-settings list below) to github.com/settings/apps/new?state=…; GitHub creates the app and redirects back with a temporary code; a one-time POST /app-manifests/{code}/conversions exchanges it for the app id, slug, private key, and webhook secret, which the operator drops into their .env. contents:write is required only for ADR047 agent-session delivery; ordinary deploy/list/commit tokens explicitly narrow it back to contents:read, while an agent token is further narrowed to its one target repository. Existing installations must approve this added App permission before agent-session pushes can work. This is the same "who owns the app for self-hosters" question that blocks GitHub social login (w4/003) — the manifest answer unblocks it.

Manifest generation is a small helper; wiring the created values into config is manual (secrets stay out-of-band, ADR012-auth.md), so the manifest conversion endpoint is not automated by bex — the operator pastes the four values. This keeps the private key on the same out-of-band path as every other platform secret.

Required App settings (corrected 2026-08-21 after the live production walk). The connect and claim flows only work when the App itself is configured with, on its GitHub settings page:

  1. a Redirect URI (callback URL) of https://<api origin>/v1/git/callback — GitHub's OAuth flows redirect here with code + state (+ installation_id on the install flow);
  2. "Request user authorization (OAuth) during installation" enabled — this is what makes a first install round-trip the callback at all, and what carries the single-use user code the admin proof exchanges;
  3. the webhook URL + secret (§7);
  4. a generated client secret (paired with the client id, §3).

The Setup URL is deliberately unused: GitHub disables it when setting 2 is on, and in that mode the post-install redirect goes to the Redirect URI instead. The original text here named "callback/webhook/setup URLs", which was wrong in a way that mattered: production's App was created with none of settings 1, 2, or 4 — so every GitHub-side install silently dead-ended on github.com/settings/installations/<id> with no redirect back to bex, and no binding was ever possible. Nothing in bex surfaced this (ADR078 §7's fail-at-start + startup warning now do for the missing OAuth pair; the missing Redirect URI still manifests only as GitHub never returning — check these four settings first when connects go nowhere).

3. Config is env-var-based; unset ⇒ 503

Five variables configure the app and its installation-admin proof, all read once at startup like every other optional feature:

Variable Meaning
BEX_GITHUB_APP_ID numeric app id — the iss of the app JWT
BEX_GITHUB_APP_PRIVATE_KEY the app's RSA private key (PEM), an out-of-band secret (ADR012-auth.md) — signs the app JWT (RS256); its PEM bytes also HMAC-sign the short-lived browser callback state
BEX_GITHUB_APP_SLUG the app's slug — builds the install URL github.com/apps/<slug>/installations/new
BEX_GITHUB_APP_CLIENT_ID the app's OAuth client id — exchanges the callback's single-use user code
BEX_GITHUB_APP_CLIENT_SECRET the app's OAuth client secret, kept out of band — proves the callback user administers the installation before it is bound

Any of the first three unset ⇒ the github service is nil ⇒ every git-connect verb (connect, claim, callback, get/delete connections, list repos) returns 503, matching BEX_OPENBAO_URL/BEX_CP_DB_URI. If either OAuth variable is unset, reads remain available for existing connections but — since w5/m75 (ADR078 §7) — connectGit/claimGit refuse up front with an actionable error (previously they minted install URLs whose callbacks were guaranteed to 503 only after the user walked the whole GitHub flow, which is exactly how production ran undetected), the callback still fails closed, and bex-api logs a loud startup warning for the half-configured state. The client id additionally builds the claim flow's OAuth authorize URL (ADR078 §3a). The push webhook's second key, BEX_GITHUB_WEBHOOK_SECRET, is introduced by w2/m9 (the app's webhook HMAC key); it is independent of these five and gates only the webhook's GitHub-signed path.

4. Connections live in the control-plane Postgres

A connection (which GitHub installation this workspace may deploy from) is durable state, so it lives in the control-plane store (git_connections, keyed by the composite (workspace_id, installation_id) since the 2026-09-16 N:N revision — a workspace holds N connections under BEX_MAX_GIT_CONNECTIONS_PER_WORKSPACE, and an installation may serve N workspaces under BEX_MAX_WORKSPACES_PER_GIT_INSTALLATION, each binding independently proved by the flow below; the full cardinality decision, and why the earlier one-workspace-per-installation rule did not survive, is ADR078 §2), which requires BEX_CP_DB_URI — consistent with ADR003-control-plane.md's opt-in. Recording a connection requires two independent checks: the callback's single-use OAuth code must identify a user who administers that installation, and an app-JWT lookup (GET /app/installations/{id}) must confirm that the installation belongs to this GitHub App and provide its account login. Either failure is rejected before persistence. App-level visibility alone is only defense in depth; it does not prove the callback user may bind the installation to a workspace.

Three proofs, one principal (w1/m67 F3, revising the two-proof design above). Those two checks were individually sound and jointly insufficient, because they authenticated different people. The signed state proved "a bex admin authorized workspace X"; the OAuth code proved "the browser here administers installation Y". Nothing tied them together — so an attacker could start a connect for its own workspace and hand the resulting install URL to a victim GitHub org admin. The victim's installation was genuine, the victim's admin proof was genuine, and the victim's repositories ended up bound to the attacker's workspace. The unique installation→workspace binding added in w1/m65 then made it a lockout too: the rightful workspace could no longer claim its own installation.

The flow now carries a server-side, single-use transaction (github_connect_transactions, migration 0071):

  1. StartConnect mints a random nonce and records {nonce, workspace, initiating subject, expiry}; the signed state carries only the nonce, so possessing a state authorizes nothing — it merely names an attempt.
  2. The callback consumes that row atomically (DELETE … RETURNING, so a replay finds nothing) and requires the presenting bex subject to equal the initiator. The Kratos session cookie is SameSite=Lax, so it does ride GitHub's top-level GET redirect; an anonymous callback is refused outright.
  3. The installation-admin OAuth proof and the app-JWT installation lookup run as before.

The nonce is consumed even when a later step refuses, so a probed or rejected attempt cannot be retried against a different installation. Unknown, expired, and already-consumed nonces are refused identically.

5. Surfaces — REST/GraphQL/MCP/UI, with the repo surface a bex superset

The connection and repo list are exposed on all four surfaces over one core (the ADR006-bex-api.md one-core/thin-adapters rule):

  • REST: POST /v1/git/connect → {installUrl} (a 15-minute HMAC-signed state credential naming the server-side transaction); POST /v1/git/claim → {claimUrl} (the ADR078 §3a OAuth-authorize flow for already-installed accounts); GET /v1/git/callback?state=…&code=…[&installation_id=…] — the App's OAuth Redirect URI (not a Setup URL, corrected 2026-08-21): the install flow arrives with installation_id, the claim flow without one, and the callback verifies state, exchanges the single-use OAuth code for the admin proof, binds, then redirects to the dashboard via BEX_DASHBOARD_URL; GET /v1/git/connections + DELETE /v1/git/connections/{installationId} (the multi-connection surface, w5/m74); GET /v1/repos (aggregated across the workspace's connections, account-annotated). The singular GET/DELETE /v1/git/connection remain as deprecated single-connection aliases, and the not-connected view no longer advertises a bare installUrl (only connectGit's stateful URL can bind).
  • GraphQL: gitConnections (+ deprecated gitConnection) + repos/repoBranches queries; connectGit/claimGit/disconnectGit(installationId) mutations.
  • MCP: list_repos and list_git_connections tools (+ deprecated get_git_connection) — the agent-facing payoff ("which repos can I deploy?" + "which accounts are connected?"). Connect/claim are deliberately absent from MCP: both are browser ceremonies an agent cannot complete.
  • UI: a Settings → "Connect GitHub" card listing all connections with per-row disconnect, "Connect another account", and "Claim installed account"; every git surface is scoped to the selected workspace (ADR078 §6).

GET /v1/repos and the MCP repo tools are bex extensions (supersets): Render lists repos only through its private dashboard API and its MCP has no repo tools, so the comparison target is bex's own cross-surface consistency, not a Render shape — flagged in-code and recorded in ADR018-render-parity.md § "bex ahead of Render". The callback authenticates differently from the rest: GitHub redirects a browser to it with no bearer or dashboard cookie, so GET /v1/git/callback is the one exact method+path exception to the shared HTTP auth gate. It requires both the HMAC-signed, expiring state — which GitHub carries through a first install of the App and through the OAuth authorize flow, but strips for already-installed accounts (their "Configure" links go to github.com/settings/installations/<id>; verified live 2026-08-20, which is why the claim flow exists — ADR078 §3a) — minted only after StartConnect/StartClaim authorizes can_manage, and GitHub's single-use user OAuth code. The callback verifies the state signature and expiry, consumes the single-use transaction, matches the initiator, then exchanges the code and proves the user administers the exact installation being bound (browser-supplied id on the install branch; server-resolved from GET /user/installations on the claim branch) before the independent app lookup in §4. Missing, tampered, expired, or unprovable credentials are rejected and redirect to /settings?git_error=<bounded-code> for visible dashboard feedback; callback redirects set Referrer-Policy: no-referrer so the credential-bearing API URL does not leak to the dashboard. There is no alternate bearer/session path that can bind an installation without those browser-flow credentials.

6. Private-repo clone — a token in a Secret, the operator stays GitHub-free (w2/m9)

The build mechanism must clone private repos without knowing about GitHub. So:

  • Operator: an optional App.spec.cloneSecret names a k8s Secret (in the App's namespace, key token). The operator relocates the opaque token into the dedicated BEX_BUILD_NAMESPACE (bex-build in production) when necessary and exposes it only to a short-lived clone init container. That container performs an explicit shallow Git fetch into the shared source volume; BuildKit subsequently receives only the local checkout and never the token, a BuildKit secret, or a remote Git context. Unset means the same clone phase performs a public fetch. The operator never mints or refreshes tokens—an absent or expired Secret fails the clone with a clear build condition. The original private-repository flow was verified live on production on 2026-07-12; the phase-separated custody and adversarial Dockerfile boundary were verified on CAPD on 2026-07-19 by verify-build-isolation.sh.
  • bex-api: on every deploy-triggering verb (Create/upsert, MCP deploy, and the webhook redeploy) whose spec.repo belongs to one of the workspace's connections, bex-api mints a fresh installation token, writes/refreshes the <app>-clone Secret, and sets spec.cloneSecret — so each build starts with a token minted seconds ago. Public/unconnected repos are untouched. The clone Secret is labeled managed-by bex-api and removed by the service-delete cascade. Resolution is two-step since w5/m74: the deploy path resolves the workspace from the App CR's bex.co/tenant label (falling back to the caller's tenant, then the default workspace) — this lets the no-identity push webhook still find a tenant-owned connection — and then selects, within that workspace's connection set, the connection whose account_login matches the repo URL's structurally-parsed owner (ADR078 §4); no owner match ⇒ public-clone behavior, never a wrong-installation token.

7. Zero-config push-to-deploy — the app webhook, a second accepted key (w2/m9)

The GitHub App's one app-wide webhook delivers push events (signed with BEX_GITHUB_WEBHOOK_SECRET) for every installed repo to the existing POST /v1/webhooks/git. The handler verifies X-Hub-Signature-256 against both BEX_WEBHOOK_SECRET (the existing manual key) and BEX_GITHUB_WEBHOOK_SECRET in constant time — valid under either ⇒ accept; 503 only when neither is set. GitHub's own lifecycle deliveries (ping, installation) get a 200 no-op when the signature is valid (never a 401 on GitHub's health checks). Since w7/m66 the handler also acts on branch deletions — a push with deleted:true (git push --delete), and, for UI/API deletions, the separate delete event — recording a branch_deleted service event and disabling auto-deploy for any service tracking the deleted branch. Catching the UI/API case requires the manifest (or the app's settings) to subscribe to the delete event in addition to push; the git-push case works with push alone. Everything downstream of signature verification — repo canonicalization across URL forms, branch/rootDir match, autoDeploy gate — is reused unchanged from ADR017-deploy-from-chat.md. The result: a git push to a tracked branch of an installed repo redeploys, with no per-repo webhook configuration.

7a. Push-deliverability is reported, not assumed (w6/m99)

§7's guarantee is scoped to an installed repo. bex's Public Git URL tab accepts any github.com URL, including one outside every connection's grant — an ordinary state (and the documented workaround for w6/m97), where GitHub sends no push event at all and no auto-deploy can ever fire. Until w6/m99 nothing said so: the stored spec.autoDeploy boolean defaults on for every repo-backed create (Render's own behavior, deliberately kept), REST/GraphQL/MCP reported only that boolean, and the dashboard's Build & Deploy hint inferred the mechanism from workspace has any connection && the URL says github.com, promising "redeploys automatically via the GitHub app" for a repo that can never deliver one.

The service read path therefore carries a separate pushDeliveryMethod field — github_app | manual_webhook | none (image-backed, nothing to deliver) | unknown (GitHub unreachable; never guessed in either direction). It is the on/off setting's missing companion: whether that setting has a delivery path at all.

Two properties make it trustworthy:

  • One predicate, two callers. It is answered by github.Service.repoGrant — the same per-owner connection lookup plus installation grant check §6's clone-token mint runs — reached through CloneTokenSource.RepoGranted, which returns the verdict without minting a credential the read path has no use for. A deploy and the claim about deploys cannot drift.
  • Computed on read, never snapshotted. A grant is edited on GitHub's side at any time; a create-time snapshot would just be a staler version of the same lie. Because answering it costs GitHub round-trips, exactly one verb pays — the by-id service read that REST GET /v1/services/{id}, GraphQL server(id)/service(id) and MCP get_service all route through — memoized per (workspace, repo) for a minute and coalesced across concurrent readers. The service list deliberately omits the field rather than putting a round-trip per repo on the hottest read path; absent there means "not computed on this projection", never "no".

8. Render-parity status & remaining plan (2026-08-21)

Measured against the deep-researched Render control-surface model and the live walk of a real Render account (both recorded in ADR078 § What Render actually does): Render's four surfaces are the user credential (Account Security: connect / "Configure on GitHub" / "Disconnect credential"), the installation (github.com only), the service pointers ("Use My Credentials" + Source/Branch edit), and nothing at the workspace level.

Parity achieved or exceeded (no further work):

  • Install the App → granted repos appear in the create flow, private clones work, push-to-deploy needs no per-repo setup — this ADR's original target, shipped w2/m8+m9.
  • Multi-account repo picker (org + personal in one list, account-grouped) — w5/m74; Render gets this through the creating user's identity, bex through the workspace's connection set.
  • Already-installed accounts bind without reinstalling — the w5/m75 claim flow; Render never needs this only because its user-bound model reads installations instead of binding them.
  • Per-connection disconnect, "manage grants on GitHub" deep link, repo-list-on-demand freshness — equivalent on both platforms.
  • Build filters + root directory (auto-deploy trigger scoping) — already shipped (SetBuildFilter, rootDir); trigger-scoping parity holds.
  • API-level source swap: PATCH /v1/services/{id} accepts repo/branch/image (w1/073), and — w5/m76 — the dashboard now exposes one Source card for both repo- and image-backed services. Its Edit dialog reuses the account-grouped create picker, chooses a connected repo or public Git URL + branch, or a container image + registry credential. GraphQL's existing setRepo accepts the branch atomically and setImage/imagePath covers images; MCP update_service carries the same three source fields as a bex extension. The transition clears the other source kind, preserves autoDeploy, opens no deploy row, and marks the source pending so the operator retains the active release until the next deploy generation, when the new repo's owner-scoped clone secret is minted. This matches Render's Update Service API contract and closes the last remaining item of this plan. Dashboard divergence verified 2026-08-25: Render's May 11 Update Source dialog immediately deploys and reconfirms runtime/build/start settings; m76 deliberately keeps the API's save-first behavior and the existing separate build-setting editors, and excludes static/cron variants from the unified card.

bex ahead (w6/m99): pushDeliveryMethod (§7a) has no Render counterpart, and the situation it describes is unreachable on Render — a Render service can only be created from a repo its connected credential already covers, so "auto-deploy is on but nothing can deliver a push" cannot arise there. bex's Public Git URL tab is what creates the state, so bex owes the disclosure. The parity requirement is therefore internal: REST, GraphQL, MCP, and the dashboard hint must all report the one value (asserted together in internal/apps/pushdelivery_test.go), not matched against an upstream field that does not exist.

Deliberate divergences (recorded, not gaps — ADR078 §1): no per-member Git credentials, no per-service credential pointer ("Use My Credentials" has no bex equivalent because deploy tokens come from the workspace's owner-matched installation), no direct-github.com auto-bind (the claim flow is the sanctioned recovery). Recorded non-goals: GitLab/Bitbucket providers and PR previews (.pm/DO_NOT_DO.md).

Remaining plan:

  1. Dashboard "Update Source" — shipped w5/m76 (see "Achieved" above): a unified repo/branch/image Source card, atomic GraphQL and MCP source fields, and operator-enforced no-auto-deploy semantics.
  2. Grant-staleness UX — neither platform auto-refreshes the repo picker after a GitHub-side grant edit; Render's remedy is its deep link, which bex mirrors. Optional polish only (an explicit "refresh" affordance); not scheduled.

Nothing functional remains: every Render access granularity has a bex answer (mapping table in ADR078), and the one service-configuration item Render had that bex lacked (source swap in the UI) is now shipped.

Alternatives considered

  • Personal access token — rejected: long-lived stored secret, no per-repo grant, no app-level webhook (push-to-deploy stays manual).
  • Plain OAuth app — rejected: authorizes as a user (broad, user-scoped, dies when the user leaves), no installation model, no per-installation webhook. It answers "log in with GitHub," a different feature (w4/003).
  • A single hosted bex GitHub App — impossible for an open-source, self-hosted product: each operator owns their install, their private key, their webhook. The manifest flow makes per-operator app creation one click.
  • Storing the connection in a k8s CR / OpenBao instead of Postgres — rejected: a connection is workspace-scoped relational state that the control-plane store already owns; OpenBao is for tenant credentials, not platform metadata.
  • Teaching the operator to mint tokens — rejected: violates the DB-free, mechanism-only operator boundary. bex-api mints; the operator consumes an opaque Secret.
  • A bespoke POST /v1/deploy-style clone endpoint — unnecessary: the token/Secret write rides the existing Create/redeploy verbs (ADR017-deploy-from-chat.md), no new verb.

Consequences

  • Private repos become first-class deploy sources. Connect once → the granted repos list on every surface → deploy clones them with a fresh token.
  • Push-to-deploy needs no per-repo setup. The app's app-wide signed webhook covers every installed repo; the manual BEX_WEBHOOK_SECRET path stays supported (a repo not in any connection, or a self-hoster who hasn't made an app).
  • Accepted limitation — the 1h token and slow operator retries. Installation tokens live 1h. bex-api mints a fresh one at every deploy trigger, so a normal build (minutes) is fine. But an operator-side build retry more than 1h after its trigger finds an expired clone token and fails until the next deploy re-mints. This is accepted (not worked around) because refreshing tokens for arbitrarily-delayed retries would require the operator to hold GitHub credentials, breaking the mechanism-only boundary. The failure is a clear build condition, not a silent public-clone fallback.
  • Operational changes never consume the token. Manual scale, suspension/resume, autoscaling, routing, and notification changes reconcile against the active artifact/revision; they neither refresh nor read spec.cloneSecret, create a build/pre-deploy Job, or add a deploy-history row (w2/m56). Refreshing the token on scale would hide an incorrect build dependency rather than fix it.
  • Trust boundary. The app private key (in .env, out-of-band) can mint tokens for every installation of the app and HMAC-sign 15-minute workspace callback state; the webhook secret can trigger redeploys of matching installed repos — the same trust shape as the other platform secrets. Installation tokens expire in 1h. Deploy/list/commit tokens explicitly request contents:read + metadata:read; ADR047 session tokens request contents:write + metadata:read and exactly one repository, after the server-side bex-agent/* mint gate.
  • Many-to-many workspaces and installations (superseding the original "single workspace for now", and then the N:1 rule). A workspace connects up to BEX_MAX_GIT_CONNECTIONS_PER_WORKSPACE installations (org + personal accounts in one repo picker), and — since 2026-09-16 — one installation may serve up to BEX_MAX_WORKSPACES_PER_GIT_INSTALLATION workspaces, so the same GitHub account can back more than one workspace without reinstalling or disconnecting. Each binding still requires the full three-proof sequence of §4, so two workspaces holding one installation means its administrator asserted it twice, deliberately. The cardinality decision, owner-scoped consumption, claim flow (including deferred claim selection), push-webhook fan-out, and dashboard workspace scoping live in ADR078.
  • Browser callback credentials (w2/m34, extended by the claim flow). The dashboard begins through authenticated connectGit (stateful install URL) or claimGit (OAuth authorize URL). GitHub passes the signed state back unchanged and adds a single-use OAuth code; bex-api verifies both without requiring the dashboard's host-scoped Kratos cookie, binds the validated installation, and returns the user to /settings. Bearer/session identity does not bypass either callback credential.

Verification

  1. Connect + list — with the five BEX_GITHUB_APP_* vars + BEX_CP_DB_URI set and the App's Redirect URI + request-OAuth-during-install configured (§2): POST /v1/git/connect → install URL with signed state; a first install on a real account (private repo granted) in an ordinary dashboard browser with no bex-api cookie → GitHub redirects to the OAuth-callback Redirect URI, which verifies state and the user OAuth code, proves installation administration, and records the installation in the workspace that initiated it; the browser lands on /settings showing the connection. An already-installed account binds through the claim flow instead (ADR078 §3a — verified live on production 2026-08-20). Missing/tampered/expired state, a missing/invalid code, and a callback without the OAuth verifier all fail closed and persist nothing. GET /v1/repos, GraphQL repos, MCP list_repos return the workspace's connections' repos including private ones, account-annotated; DELETE /v1/git/connections/{installationId} removes one connection's repos from the aggregate; any of the three core app vars unset ⇒ every git-connect verb 503s, while either OAuth var unset ⇒ connectGit/claimGit refuse at start and the callback fails closed. (w2/m8 + w2/m34 + w5/m74 + w5/m75 DoD.)
  2. Private deploy + hands-free push — create a service from the private repo → the in-cluster build clones with the installation token (no auth error) → live URL; git push to the tracked branch → the app's signed delivery redeploys (new revision in deploy history) with zero manual webhook config; autoDeploy: false suppresses the next push; a tampered signature → 401. (w2/m9 DoD.)