ADR-035: GitHub write scope lives in a separately permissioned response adapter
Status: Proposed. Consumes docs#343 / PR #372 (write-BACK taxonomy); does not re-derive it.
Context
Releasing or rejecting a held deployment requires POST /repos/{owner}/{repo}/actions/runs/{run_id}/deployment_protection_rule
with state: approved|rejected. GitHub lists that endpoint under the Deployments: read and write
permission with an installation access token — and there is no narrower "review a protection rule only"
scope. The same scope also permits creating deployments, deleting deployments, and posting deployment
statuses (research/github-provider.md, primary source docs.github.com).
Our constraints: connectors are read-only and never write to source systems (AGENTS.md; enforced as a
build rule). The MCP surface is a frozen six-tool contract whose only client is GET-only by
construction (src/mcp/server.ts:33-46; SAFE_METHOD / assertSafeMethod in the MCP api-client) — an
approve callback structurally cannot live there. No GitHub connector exists in sv0-connectors
(integrations/ = aws, azure-foundry, azure-sentinel-soc, entra-servicenow, jira-cloud) and nothing in
either repo calls the GitHub API.
Decision
-
One gate App. Exactly one GitHub App owns the custom deployment protection rule on the gated environment, receives the
deployment_protection_ruledelivery, and posts the release/refuse decision. Its permission set is exactly Actions: read, Deployments: read and write, and the singledeployment_protection_ruleevent subscription. A separate write-only identity is not available: GitHub documents that "GitHub Apps can only review their own custom deployment protection rules" — the rule belongs to whichever App is enabled on the environment, and only that App may callPOST /repos/{owner}/{repo}/actions/runs/{run_id}/deployment_protection_rule(research/github-provider.md§1.4, sourced to docs.github.com REST "Review custom deployment protection rules for a workflow run"). A split-identity design cannot release a hold at all. -
The response adapter is a write-BACK subsystem in the docs#343 taxonomy: deterministic, approval-gated, allowlisted operations only (
approve,reject,post status report), and an emergency off switch (ADR-037). Delivery is leased and reconciliation-first, not idempotent-and-retry. The adapter claims an authorized decision with an atomic claim and an expiring lease before any POST, and reads GitHub's current approval state (GET .../runs/{run_id}/approvals) before every retry — a read-back after the fact is not sufficient, because a POST timeout or a 4xx does not prove the deployment is still held, and a blind retry can produce a second decision. Where the state cannot be established the decision goes toambiguousand pages. Refusal gets the same lifecycle as release; an undelivered refusal leaves the run waiting exactly as an undelivered release does. The full state machine and the CAS rules live in ADR-036 Decision 4; this ADR owns only the credential boundary that path runs inside. -
Least privilege is achieved by process and credential isolation, not by a second App identity. The gate App's write credential is held only by the response-adapter process, which: is deployed and credentialed separately from the API; holds no handle to the platform's tenant graph — no entity, finding, evidence-pack or correlation access; is unreachable from a connector, an MCP tool, or a scan/evaluate worker; sits behind a lint-enforced import boundary (the
src/mcpdepth-ladder ateslint.config.js:55-110, mirrored by an isolation test, is the shipped precedent); and mints a per-decision installation token scoped byrepository_idsand{"deployments": "write"}rather than holding a long-lived org-wide token.The adapter is also what receives the webhook. An earlier draft put the receiver in the platform's API process; that is settled the other way. The adapter holds the App credential and must validate the delivery HMAC, it is the only component that can answer its own protection rule (Decision 1), and it already has a separately credentialed durable store to ACK into. The platform's API process keeps the read and confirmation surfaces and never loads the write credential.
Correction to the earlier draft's "holds no MongoDB handle". That phrasing is now wrong in a way that matters: ADR-036 Decision 4 requires a durable inbox written before the webhook ACK and a durable dispatch outbox with leases, and ADR-037 Decision 3 requires a cached signed per-environment failure policy resident in the adapter before an outage. All three are state the adapter must own, and an adapter that must ask the platform for them is exactly the adapter that cannot function when the platform is unreachable. So: the adapter has its own durable store, separately credentialed, holding
gate_inbox(received deliveries, written before the 2XX),gate_dispatch(outbox, atomic claims and lease records) and the cached failure policy — and no access to the platform's tenant data. The four platform-Mongo collections —gate_decisions,gate_installations,gate_policies,decision_records— stay in the platform (ADR-036 Decision 3); the adapter never reads or writes them directly. The surviving isolation claim is stated in exactly these words: the adapter holds no handle to the platform's tenant graph — the property preserved is the write credential and the graph never meet in one process, not the adapter is stateless. Sizing note: this store is a named build item that the first draft's phrasing hid. -
Status reports are the in-hold communication channel. Omitting
stateposts a status report — up to 10 per deployment, 1024 chars, Markdown — which is how rehearsal/verdict context reaches the reviewer while the deployment is still held, without exercising release authority. -
Credential isolation is proven, not asserted, using two documented mechanisms together: environment-scoped secrets released only after all protection rules pass, plus an OIDC trust policy with
StringEqualsonsub == repo:ORG/REPO:environment:NAME. The permissiveStringLike repo:ORG/REPO:*pattern that appears in GitHub's own docs defeats the gate entirely and is banned in the pilot. -
The identity separation that does apply is gate App vs. a future read-only GitHub connector App. If we ever ingest GitHub as a source system, that is a different App: separate registration, separate installation, read-only permissions, never
Deployments: write, and on the ingestion path where the read-only connector model binds it (research/github-provider.md§1.6). The gate App is never used for ingestion, and the connector App is never used to answer a hold.
Alternatives considered
- Write scope inside a new GitHub connector. Rejected: violates the read-only connector model, and would put a production-mutating credential on the ingestion path.
- Write scope on the platform API itself. Rejected: the platform's blast radius is the wrong place
for a repo-scoped
deployments: writetoken, and it makes the kill switch a platform deploy. - Two Apps: a read identity that receives the event, a separate write identity that answers it. Not available, not merely rejected — GitHub Apps can only review their own custom deployment protection rules, so the second identity could never post the decision (Decision 1). Recorded here because an earlier draft of this ADR specified it.
- A seventh MCP tool that approves. Structurally impossible on the current GET-only client, and undesirable — approval must not be reachable from a model-driven surface.
Consequences
- Accepted risk, named:
Deployments: read and writeis broader than we need (it can create and delete deployments) and GitHub offers no narrower scope and no second identity to hold it. This goes in the adapter's threat model with compensating controls: allowlist, leased dispatch that reconciles against GitHub's approval state before every retry (Decision 2), per-tenant scoping, per-decision repository-scoped tokens, and audit of every call. - One GitHub App registration to manage, install and rotate; installation IDs are bound per tenant in
gate_installations(ADR-036). The write credential is a short-lived, repository-scoped installation token minted per decision by the adapter process — not a stored org-wide token. - This adapter is a single point of failure for release, by construction, and no failure posture can
remove that. Only this App may answer its own protection rule (Decision 1), so if the adapter process,
its private key, its network path or the GitHub API is unavailable, nothing can post
approvedand held deployments stay held until recovery or a customer-side admin bypass. ADR-037 Decision 3 states this as the fail-closed row; it is recorded here too because it is a direct consequence of the single-identity constraint this ADR adopts. Operational implications — redundancy, key-expiry alerting, a documented customer admin bypass — are pilot-blocking, not polish. deployment: falseon an environment makes custom rules fail the job while granting the job full environment-secret access — a gate-DoS and secret-release configuration. It must be excluded from the gated environment by the onboarding check.- Admin bypass is ON by default ("Start all waiting jobs"), and deleting the protection rule needs only repo admin. Any claim that the gate holds requires "Allow administrators to bypass" to be deselected and verified; detecting rule removal is our job, not GitHub's.
- Plan floor: custom deployment protection rules in private or internal repositories require GitHub Enterprise (verbatim from GitHub's availability reusable). Team is not sufficient. This is a named design-partner qualification criterion. SecurityV0's own org is on Enterprise, so the dogfood path is not blocked.
- The mechanism is in public preview and "subject to change" — our enforcement point has no deprecation contract. State this as product risk, do not bury it.