Manage · Controls

Arm a control and know what it holds

Controls is the hosted table where an owner or admin arms one of four controls on one connected repository. An armed advisory control is named on the RepoOps review check of every pull request and holds nothing. Merge hold, escalated to blocking mode, fails that check on a high-severity finding. The Control matrix is the read-only grid over the same rows.

For: the owner or admin who sets a team's repository posture, and the reviewer who reads it on a pull request

What it does, and why it helps

The catalog has four controls: Risk score, Cost guard, Redaction and Merge hold. Each repository in the team's GitHub inventory shows all four, and every one reads dormant until an owner or admin presses Arm (advisory). The write lands in one table, keyed by team, repository and control, and writes an audit event with the prior state. Every read and write is bounded to the team resolved from your session, and the repository key is checked against that team's own inventory before anything is written.

When the GitHub App reviews a pull request it reads the armed rows for that repository and prints one line on the RepoOps review check run and the review comment: which controls are armed, and whether any of them held the merge. An advisory control does nothing beyond that line. Only Merge hold has a blocking mode, and it holds a merge only when four things are true at the moment of the run: the operator set REPOOPS_ALLOW_BLOCKING_CONTROLS, the team is on a paid plan, the row is armed in blocking mode, and the review found a high-severity finding. If RepoOps cannot read the control state or the plan, it applies no control, blocks nothing, and says so on the check run.

The Control matrix at /team/control shows the same rows as a grid, one repository per line and one column per control, with armed, dormant or a dash. The dash means RepoOps has no published brain for that repository yet, or could not read the control state, so it does not claim a posture. The matrix shows armed or dormant and never the mode; Controls is the page that shows which rows are blocking today.

The pain. A team wants a rule on some repositories and not others, and wants to know afterwards who turned it on. A rule that only exists in someone's head is not a rule, and a rule that blocks merges by surprise is worse.

The point of view. A control should say on every pull request what it does. Advisory means recorded and named, and nothing else. Blocking is a separate, gated step that an owner takes on purpose, can undo at any time, and that fails open when RepoOps cannot see its own inputs.

What gets easier. Reading the posture. One table per team shows each repository, each control, its state and mode, who last changed it and when, and when a pull request last reported it. The matrix shows the same posture at a glance across the inventory.

When it helps. A team with the RepoOps GitHub App installed on an organization, on a paid plan, that wants a named posture per repository and one real merge gate it can turn on and off without an operator.

Its limits. Three of the four controls have no effect beyond their name on the check run and the record. Merge hold in blocking mode reads only the review's own high-severity findings, which today are a committed secret, an AWS key id or a private key block on an added line. It does not lower a merge block the operator set deployment-wide. A lapsed plan or an unset operator gate turns every armed blocking row into advisory on the next run without changing the row.

Understand it in 30 seconds

30.1 s, captions on. Narration: Microsoft Zira Desktop (provisional voice; an approved narration source is pending).Transcript
Read the narration
  1. 0:00 A control reads armed.
  2. 0:02 Would it hold a merge, or only say so?
  3. 0:06 Advisory names itself on the check run.
  4. 0:08 Only merge hold, in blocking mode, fails it.
  5. 0:13 The check run names each armed control and what held the merge.
  6. 0:16 Unreadable state? It says so and blocks nothing.
  7. 0:23 Arm advisory first.
  8. 0:25 Escalate merge hold on purpose, and disarm any time.

Synthetic example. Read the guide

Where to find it

  • Hosted: repoops.ai/team/controls, from Team & settings in the sidebar, then Controls under Workspace utilities.
  • Desktop: hosted only.

When to use it

Name a posture on one repository

Situation. The team connected its GitHub organization. Every row on Controls reads dormant and the note says No controls armed yet.

What you do. Find the repository row, then the Risk score line, and press Arm (advisory). Confirm the dialog, which says the control will be named on the RepoOps review check of every pull request on this repository and holds nothing.

What you see. The State cell reads armed, the Mode cell reads advisory, Last change reads armed by your name with a UTC stamp, and Last reported reads not yet until a pull request runs. The next pull request's check run says Controls armed on this repository: Risk score. All of them are advisory: they are recorded against this run and hold nothing.

What it establishes. A recorded, attributed posture that every pull request on the repository names. No merge is held.

Turn merge hold into a real gate

Situation. Merge hold is armed as advisory on the payments repository. Your operator has set REPOOPS_ALLOW_BLOCKING_CONTROLS on the deployment, so the row shows Escalate to blocking instead of the note blocking off.

What you do. Press Escalate to blocking and read the confirm: a high-severity finding will fail the RepoOps review check on every pull request on this repository, and a protected branch will refuse the merge until the finding is resolved. Confirm.

What you see. The Mode cell reads blocking in red. The audit trail holds a repo.control.armed event with mode blocking and the prior state. A pull request that adds a private key block gets a RepoOps review check with conclusion failure and the line merge-hold is armed in blocking mode on this repository, so a high-severity finding fails this check.

What it establishes. One repository has one real gate, on one class of finding, that an owner can return to advisory or disarm at any time. Nothing about the other repositories changed.

Read the matrix when a repository shows a dash

Situation. The Control matrix shows a dash across one repository's row while the others read armed or dormant.

What you do. Open Repos, then Link telemetry, and bind the repository's local key so a brain snapshot can join to it. If every row on the page reads a dash, read the red card instead: Control state could not be read.

What you see. After a brain publishes, the row reads dormant on each control and armed where Controls has an armed row. The dash was RepoOps declining to claim a posture it could not see.

What it establishes. The matrix reports a posture for the repository. A dash was never a claim that nothing is armed.

Before you start

Supported versions
RepoOps v0.3.1, the hosted release this guide was read against. The check-run behaviour needs the RepoOps GitHub App installed on the organization that owns the repository.
Where it runs
Hosted only: /team/controls (the table and the actions) and /team/control (the matrix), both listed under Workspace utilities on Team & settings. The desktop app has no local copy; Local settings lists both as links to repoops.ai.
Permissions
Every member on the team can read the table. Arming, disarming and escalating need the owner or admin role, checked again server-side on the write. A member scoped to a subset of repositories sees only that subset; owners and admins are never scoped.
Connections
A GitHub organization connected through the RepoOps GitHub App, which writes the repository inventory. For the matrix to show a posture, the repository also needs a published brain snapshot; a repository without one reads a dash.
Plan
Any paid plan. The hosted dashboard redirects an unpaid team to billing, and the API answers 402 to a write that raises a posture. A disarm or a return to advisory always goes through, whatever the plan, and the webhook re-reads the plan on every run: a lapsed plan stops every blocking row from holding a merge without touching a row.

Configure it

  1. Connect the organization.

    Until the inventory has a row, Controls says No repositories yet and offers Connect your GitHub organization. The inventory is what the table ranges over and what a write is checked against.

  2. Arm a control as advisory.

    On the repository's row, press Arm (advisory) beside the control. The confirm says what advisory means: recorded, shown here, named on the RepoOps review check of every pull request on this repository, holding nothing. Arming always lands as advisory; blocking is never a side effect of this click.

  3. Read the next pull request.

    The RepoOps review check run and the review comment carry one line naming the armed controls and their mode. Last reported on the table fills in when the run stamps it; not yet means no pull request has run since the control was armed.

  4. Only then, escalate merge hold.

    Escalate to blocking appears on the Merge hold row only after it is armed and only when the operator has set REPOOPS_ALLOW_BLOCKING_CONTROLS. With the gate off the row shows the note blocking off, and a blocking write answers 403. The confirm names the effect: a high-severity finding fails the check and a protected branch refuses the merge.

  5. Return to advisory or disarm when it is no longer wanted.

    Both actions are allowed with the gate off and with the plan lapsed. A disarm on a blocking row asks first, because it removes a gate on the next pull request. Disarm cannot remove a merge block the operator set deployment-wide.

  6. Check the matrix for the whole inventory.

    The Control matrix reads the same store and shows armed or dormant per control across every repository in your scope. It shows no mode and takes no action; come back to Controls for either.

SettingWhereA sensible choiceWhy it matters
armed (per repository, per control)Controls, the Action column: Arm (advisory) and Disarmadvisory on the repositories where the name on the check run is wantedAn armed advisory control is recorded, attributed and named on every pull request. It holds nothing.
mode (merge-hold only)Controls, the Action column: Escalate to blocking and Return to advisoryadvisory until the repository has earned a gateBlocking is the only mode that fails the RepoOps review check, and only on a high-severity finding. The other three controls have no blocking mode and a blocking write on them answers 400.
REPOOPS_ALLOW_BLOCKING_CONTROLSthe hosted deployment's environment (operator only)unset unless the team needs a merge gateOff unless the value is 1 or true. It is read at arming time, where it decides whether Escalate to blocking appears, and again at every pull request run, so unsetting it turns every blocking row inactive on the next run.
GITHUB_REVIEW_MERGE_BLOCKthe hosted deployment's environment (operator only)the operator's call; it is not a controlA deployment-wide floor: with it set to 1 the review check fails on a high finding on every repository. Arming adds to it; disarming cannot lower it.
GITHUB_EVAL_MERGE_BLOCK, GITHUB_CACHE_MERGE_BLOCKthe hosted deployment's environment (operator only)leave as the operator set themThey fail the cost/quality and cache-hit checks. Merge hold does not touch either; nothing on Controls reads or changes them.
the team's planBillingany paid planA write that raises a posture answers 402 on an unpaid team, and the webhook applies no per-repository control for one. Lowering a posture is never gated.
ⓘ
To stop or undo
Press Disarm on the row, or Return to advisory to keep the name and drop the gate; both take effect on the next pull request and both are allowed whatever the gate or plan says. Your operator can stop every blocking row at once by unsetting REPOOPS_ALLOW_BLOCKING_CONTROLS, which changes no row.

What you should see

Advisory on one repository

Configuration. Risk score armed advisory on one repository; the operator gate unset.

Expect. State armed, Mode advisory, Last change armed by you with a UTC stamp. The Merge hold row on the same repository shows the note blocking off.

Verify. Open the next pull request on that repository. The RepoOps review check run's summary and the review comment's footer name Risk score and say all of them are advisory. The check concludes neutral unless the operator floor is set. Last reported now carries the run's stamp.

Merge hold blocking, with the gate on and a paid plan

Configuration. Merge hold armed, escalated to blocking; REPOOPS_ALLOW_BLOCKING_CONTROLS set; the team on a paid plan.

Expect. Mode reads blocking in red. A pull request whose diff adds a committed secret, an AWS key id or a private key block gets a RepoOps review check with conclusion failure and the reason line naming merge-hold. A pull request with only medium or low findings concludes neutral.

Verify. The audit log holds repo.control.armed with mode blocking and the prior state. On a protected branch that requires the RepoOps review check, GitHub refuses the merge until the finding is gone or the row is returned to advisory.

Blocking armed, but the gate is off or the plan lapsed

Configuration. A row armed in blocking mode, and either REPOOPS_ALLOW_BLOCKING_CONTROLS unset or the subscription no longer active.

Expect. With the gate off the Mode cell reads blocking (inactive) and the row holds no merge. With the plan lapsed the check run names Merge hold as advisory and applies no control. In both cases the row itself is unchanged.

Verify. The check run's posture line does not say blocking. Return to advisory and Disarm still work; a write that would raise the posture answers 402 or 403 with the reason.

The state could not be read

Configuration. Any posture, with the control table unreachable or migration 0056 not applied.

Expect. Controls shows the red card Control state could not be read, every State cell reads unknown, and the matrix shows a dash in every cell. On a pull request the check run says the control state could not be read, so no armed control was applied to this run, and nothing was blocked as a result. The operator floor still applies if set.

Verify. Reload; if it persists, ask the operator whether migration 0056 has been applied. Nothing armed was lost: the rows are read again on the next request.

Data and cost

What is captured
One row per team, repository and control in team_repo_controls: armed, mode, who armed it, when, the last pull request run that reported it, and an updated stamp. One audit event per arm, disarm or sync drop with the repository key, the control name, the mode and the prior state. Nothing about the pull request's content is stored by this feature.
Who can see it
Every member of the team reads the table and the matrix within their repository scope. A repository marked personal drops out of both unless a control is armed on it, so that an armed control is never invisible to the people who can disarm it. The posture line on the check run and the review comment is public to whoever can read the pull request; it names only catalog labels and modes, never a repository key, a team id, a person or an environment value.
How long it is kept
Rows persist until an owner disarms them, the repository leaves the team's inventory (the sync deletes the row and writes repo.control.dropped for an armed one, actor github-sync), or the team is deleted, which cascades. No retention knob and no purge exist for the rows or their audit events; the Last change column reads the newest 1,000 audit events.
What leaves the machine
One line on the GitHub check run and one in the review comment, through the GitHub App's own token. No other traffic. The desktop app sends nothing to this feature and receives nothing from it.
What it costs
No model call and nothing metered. The enforcement read is one joined query per pull request run, and the stamp on Last reported is best-effort and never fails the run.

When the result differs

SymptomLikely causeNext action
Controls says No repositories yet.The team has no GitHub inventory.Press Connect your GitHub organization and grant the repositories. Rows appear after the first sync.
The table is empty and says none of this team's repositories are in your scope.Your membership is scoped to repositories the team has not connected, or to none.Ask an owner or admin to widen your repository scope. Owners and admins are never scoped.
The Merge hold row shows blocking off and no Escalate to blocking button.REPOOPS_ALLOW_BLOCKING_CONTROLS is not set to 1 or true on the deployment.Ask your operator. Until then arm merge hold as advisory; a blocking write answers 403 with the same reason.
A write answers 402.The team is not on an active paid plan and the write would raise a posture.Open Billing. A disarm or a return to advisory is never refused on this ground.
Mode reads blocking (inactive).The row is armed blocking but the operator gate is off, so it holds no merge.Nothing to fix on the row. Ask the operator about the gate, or return the row to advisory so the table says what the run does.
Last reported reads not yet on an armed row.No pull request has run on that repository since the control was armed.Open or update a pull request. The stamp is written by the run that reports the control.
A pull request with a wide-open CORS header was not held.Merge hold fails the check only on a severity high finding; open CORS and a 0.0.0.0 bind are medium.Read the finding list on the review comment. The high rules today are a committed secret, an AWS key id and a private key block.
Every State cell reads unknown, or every matrix cell is a dash.The control table could not be read, most often because migration 0056 is not applied.Reload. If it persists, ask the operator. No control is applied to a pull request while the read fails, and the check run says so.
One matrix row is a dash while the others read a state.That repository has no published brain snapshot the team can join to its key.Open Repos, then Link telemetry, and bind the repository's local key, then publish a brain from the desktop. The row reads dormant once a snapshot exists.
Last change reads dropped (repo left the inventory) by GitHub sync.The repository was removed from the App or renamed, so the sync deleted its armed rows and recorded the drop.Re-arm on the repository's new row if the posture is still wanted. No person disarmed it, and the record says so.
Disable
Disarm the row, or return it to advisory, at any time and under any plan. The operator can unset REPOOPS_ALLOW_BLOCKING_CONTROLS to make every blocking row inactive on the next run without changing a row.
Roll back
Not provided. There is no undo of a posture change beyond arming or disarming again; the prior state is in the audit event's payload (priorArmed, priorMode) in audit_events, so you can read what to restore.
Revoke access
Remove the owner or admin role from the member; the write path re-checks the role on every request. Uninstalling the GitHub App or removing a repository from it makes the sync delete that repository's rows and record repo.control.dropped.
Delete
The control rows: a disarm on a repository that has left the inventory deletes the row, and deleting the team cascades to every row. The audit trail: not provided. No route deletes repo.control.armed, repo.control.disarmed or repo.control.dropped events; they sit in audit_events.

Maintenance evidence

Feature id
controls (spine leaf controls)
Owner
GitHub connect program, Phase 2 (PR 2.4 the Control matrix, PR 2.4a the arming layer; enforcement wired in #3504, G029 and G083). Guide: LDG-0717.
Supported product version
RepoOps v0.3.1
Last verified
2026-09-15, read against origin/main at 52366bb6d; labels read from the served tab source (website/app/team/(home)/controls/page.tsx, website/app/team/(home)/control/page.tsx, website/components/RepoControlArm.tsx) and the check-run strings from control-enforcement.ts and pr-review.ts. Not run against a live tenant.
Example fixtures
No fixture file; the shapes are inline in website/app/api/team/repos/controls/controls.integration.test.ts (PGlite, migrations 0049 and 0056: arm, disarm, the gate, the audit row, tenant isolation, the unreadable state), website/lib/github/control-enforcement.test.ts (every direction of the decision and the posture line), website/lib/github/control-state.test.ts (the matrix cells), website/app/team/(home)/control-scope.test.tsx and website/lib/github/controls-scope.ts callers (member scope), and website/app/team/(home)/controls-enforcement-honesty.test.ts (the page copy pinned to the enforcement that exists).
Source references
website/lib/github/control-state.ts, website/lib/github/controls-store.ts, website/lib/github/control-enforcement.ts, website/lib/github/controls-scope.ts, website/lib/github/repos-sync.ts, website/lib/github/pr-review.ts, website/lib/review/diff-review.ts, website/lib/team-entitlement.ts, website/app/api/team/repos/controls/route.ts, website/app/api/github/webhook/route.ts, website/app/team/(home)/controls/page.tsx, website/app/team/(home)/control/page.tsx, website/components/RepoControlArm.tsx, website/db/migrations/0056_team_repo_controls.sql, website/.env.example
Documentation review
Independent review requested on the slice pull request; not yet recorded.
Video review
Narrated story rendered and published 2026-09-26 (render a16c221634f8, LDG-1018) with the breadcrumb Team & settings, checked against main at 0f24215b5. One frame, the captions and the transcript were reviewed by the authoring agent, not an independent reviewer; the audio was not listened to by a person. Narration is the provisional Windows voice until LDG-0721.

Last updated