Skip to content

feat(mcp): classify every v1 RPC and lint the classification complete (1b-1) - #21180

Open
vsai12 wants to merge 9 commits into
mainfrom
feat/mcp-p1b-classification
Open

feat(mcp): classify every v1 RPC and lint the classification complete (1b-1)#21180
vsai12 wants to merge 9 commits into
mainfrom
feat/mcp-p1b-classification

Conversation

@vsai12

@vsai12 vsai12 commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Every v1 RPC now carries mcp_method_class, and CI fails on one that does not. That last clause is the point of the PR: five rounds of this series have found MCP-reachable methods one batch at a time, and a new RPC could always arrive unclassified. It cannot now.

Only FORBIDDEN is enforced. READ, WRITE and EXCLUDED are recorded classifications with no request-time reader — the gate that selects between them is 1b-2. The enum comment says so, the interceptor comment says so, and the interceptor's dispatch test pins that all three still dispatch. The exception is FORBIDDEN, which the existing interceptor enforces the moment it is annotated; see the behavior change below.

The classification

Class Methods Meaning
READ 47 served to a read-only session and above
WRITE 40 served to a read-write session only
EXCLUDED 93 served by no mode this phase ships
FORBIDDEN 28 never served, enforced today
total 208

backend/api/v1/testdata/mcp_method_classification.md renders it row by row. It is a rendering, not the source of truth — the annotations are — and a stale file fails CI. Regenerate with:

MCP_INVENTORY=write go test ./backend/api/v1/ -run TestMCPClassificationInventory

Seeded from the closed #21141's inventory, re-verified against current main handler by handler. Its ai.op.* identifiers and capability universe are not revived.

Admin-class exclusion is a fourth class, not FORBIDDEN

EXCLUDED is new. It means no phase-1 mode serves the method, and it is deliberately not FORBIDDEN, because the two differ in whether they can ever be undone. An admin-capable ceiling — if one is ever built — could legitimately serve an EXCLUDED method; that is a product decision nobody has made. It could never serve a FORBIDDEN one, because FORBIDDEN names a mechanism that breaks the MCP boundary itself. Folding them together would put the ordinary admin API into the same durable-never set as credential minting, and any future widening would have to re-litigate both at once.

mcp_exclusion_reason is required on every EXCLUDED row, so an exclusion nobody wrote down cannot happen:

Reason Rows
ADMINISTERS_THE_WORKSPACE 78
RETURNS_A_STORED_SECRET 8
READS_OTHER_USERS_SQL 3
SENDS_DATA_TO_A_THIRD_PARTY 2
OPENS_AN_ADMIN_CONNECTION 2

Counterargument, pre-written: a fourth value is one more thing the gate has to switch on, and today EXCLUDED and FORBIDDEN behave identically — nothing serves either. YAGNI says use FORBIDDEN and split later. Against that: they do not behave identically now — FORBIDDEN denies at the interceptor and EXCLUDED dispatches, which the dispatch test pins — and every FORBIDDEN row carries a reason describing a credential or boundary mechanism. Ninety-three admin rows have no such mechanism, so folding them in would mean either inventing a false reason or leaving them reasonless, which the lint refuses.

The conservative exclusions

Method granularity means excluding these costs nothing elsewhere, so where a method is dangerous behind an innocuous permission it is out.

  • RolloutService/GetTaskRunSessionOPENS_AN_ADMIN_CONNECTION. Shares bb.taskRuns.list with four store-only reads, but alone opens an admin-credentialed connection to the customer database and returns other sessions' in-flight, unmasked SQL.
  • SQLService/AdminExecute — same reason. Arbitrary SQL over an admin connection, no access-check callback, no masking pass.
  • SavedQueryService/ListSavedQueriesREADS_OTHER_USERS_SQL. The successor to ListWorksheets. Its own proto comment states the property: "Bindings are ignored: the permission alone reads every matched saved query's content, private ones included," and it accepts projects/-. SearchSavedQueries is caller-scoped and stays READ.
  • QueryHistoryService/ListQueryHistories and the deprecated SQLService alias — same reason: every user's raw statements, workspace-wide under the wildcard.
  • SQLService/Export and DatabaseService/DiffSchema are WRITE, not READ. Both share bb.databases.get with plain reads, so a permission-derived classification would have served them to a read-only session. WRITE is a serving mode, not a verb: Export takes a copy of data out of the product, and DiffSchema generates migration DDL from a schema the caller supplied.
  • AIService/Chat and ProjectService/TestWebhookSENDS_DATA_TO_A_THIRD_PARTY.

RETURNS_A_STORED_SECRET — eight rows excluded for a defect, not for what they are

These are ordinary reads that belong in a serving class on their merits. They are out because their response carries a stored secret the product already redacts elsewhere. The reason is meant to go away: fixing each leak moves its row to READ as a reviewed widening, rather than leaving a quiet exposure the moment the ceiling starts serving. TestExcludedOnlyForALeak pins the population.

  • ProjectService/GetProject, ListProjects, BatchGetProjects, SearchProjectsconvertToProject copies the incoming-webhook URL out verbatim. That URL is a bearer credential, and this repo already treats it as one: redactWebhook masks it in audit rows beside the OIDC client secret and the LDAP bind password. ListProjects pages the whole workspace.
  • InstanceService/GetInstance, ListInstances, InstanceRoleService/ListInstanceRolesInstanceRole.Attribute is the raw SHOW GRANTS text the MySQL driver stores. On MariaDB that carries IDENTIFIED BY PASSWORD '<hash>', verified against mariadb:10.6 and 11.4 containers. The data-source half of the same converter gets this right and says so: "We don't return the password and SSLs on reads."
  • UserService/GetCurrentUser — returns temp_otp_secret and temp_recovery_codes when the subject is the caller, so a session reading its own profile during an MFA-setup window captures the TOTP seed. Narrowest of the three: the window is opened by the human in the console, and UpdateUser, which starts it, is FORBIDDEN.

Counterargument: classifying a method by a bug is wrong — the class should describe what the method is for, and the honest move is to fix the leak and keep them READ. Against that: the classification is inert today, so nothing is lost by excluding now, whereas a READ row whose redaction never lands is an exposure the day the gate ships. Fail-closed is this series' stated posture, and the cost is small — the built-in MCP tools do not call any of these; they are reachable only through call_api.

Behavior change: three methods newly denied on merge

ApproveIssue, RejectIssue and RetryIssueApproval, under a new DRIVES_THE_APPROVAL_DECISION reason. Anything annotated FORBIDDEN is enforced by the existing interceptor the moment this merges, so these are the only behavior change in the PR.

ApproveIssue and RejectIssue are two actions of one handler (issue_review.go reviewIssue) and it records the review decision itself: applyReviewAction requires an approver role via canReview, enforces the self-approval guard, and appends an APPROVED or REJECTED approver. An agent composes a change; it does not move its own change through the gate.

RequestIssue is deliberately NOT in this set — it is WRITE. Spec §1b-1 named four approval methods; that line grouped by RPC family and the mechanism does not agree. RequestIssue requires the issue to be already rejected, requires the actor to be the creator, never calls canReview, and records no decision — it strips the REJECTED approvers and returns the issue to PENDING for a fresh human decision. It approves nothing, so refusing it protects nothing the other two do not, and it costs the loop propose_database_change exists for: while a rejection stands both Approve and Reject hard-fail, so this is the only exit from that state. Raised by the Codex review.

RetryIssueApproval is the judgment call. It casts no vote — it re-runs approval-template finding for an issue stuck in CHECKING, and only the issue creator may call it. Counterargument: refusing it costs an agent the documented self-service recovery for its own stuck issue, and the containment is partial anyway — UpdatePlan with a specs mask and UpdateIssue on a label change both reset ApprovalFindingDone and force the same re-derivation, and both stay WRITE. Why it is in regardless: on an auto-approved result the same call activates the grant and enqueues the rollout, so it moves an issue through the gate with no human acting. The WRITE siblings are the deliberate line, not an oversight — editing a proposal is the agent's job and re-review after an edit is the system working.

The reason text claims only what the classification delivers. It does not claim an agent only executes approved work: CreatePlan, CreateRollout and BatchRunTasks are all WRITE, and both approval checks on the execution path are guarded on an issue existing, so a plan created without one reaches execution with no approval at all. Whether an issueless rollout belongs in WRITE is 1b-2's decision to take deliberately rather than inherit.

The lint

Four clauses over the compiled descriptor set, each a function over rows so a deliberately broken copy runs the same code. Nine RED tests; all green:

--- PASS: TestLintClausesFireWhenBroken/an_unclassified_method_fails
--- PASS: TestLintClausesFireWhenBroken/a_reasonless_FORBIDDEN_fails
--- PASS: TestLintClausesFireWhenBroken/a_FORBIDDEN_reason_with_no_wording_fails
--- PASS: TestLintClausesFireWhenBroken/a_reasonless_EXCLUDED_fails
--- PASS: TestLintClausesFireWhenBroken/a_served_method_carrying_a_denial_reason_fails
--- PASS: TestLintClausesFireWhenBroken/a_class_both_served_and_denied_fails_once,_not_once_per_method
--- PASS: TestLintClausesFireWhenBroken/a_class_nobody_decided_fails,_and_takes_its_rows_with_it
--- PASS: TestLintClausesFireWhenBroken/a_ceiling_mode_with_no_serving_row_fails
--- PASS: TestLintClausesFireWhenBroken/a_row_carrying_an_undecided_class_fails

On "no method is both FORBIDDEN and reachable through a serving mode": asserting that row by row proves nothing, because a row carries one class and the contradiction is impossible by construction. What can drift is the vocabulary, so the clause reads MCPMethodClass and MCPCapability out of the compiled descriptors and asserts every class is served by some mode or denied — never both, never neither — that every ceiling mode says what it serves, and that no row carries a class the tables skipped. Add a fifth class or a fourth mode and the build fails until somebody decides who serves it.

Two more pins: the three deprecated SQLService query-history aliases must match their canonical QueryHistoryService method, because the MCP surface resolves them by operation ID and a looser alias is a straight bypass; and TestExcludedOnlyForALeak above.

Debt recorded on the annotation, for 1b-2 and 1b-3

Two rows are classified where they belong and cannot be made safe by a class annotation alone, so the debt sits beside the annotation the gate PR will read:

  • SQLService/Query is READ conditionally. On every engine in EngineSupportQueryNewACL the handler classifies each statement and authorizes DML with bb.sql.dml and DDL with bb.sql.ddl — what it does is decided by its argument. 1b-3's statement clamp is what makes READ safe, and the invariant that READ_ONLY is never admitted at /mcp before the clamp is live covers the interval.

  • IssueService/CreateIssue is WRITE for the DATABASE_CHANGE issue it exists for. ROLE_GRANT and ACCESS_GRANT issues complete on creation whenever the workspace rule produces no approval template, each reaching past a method that is EXCLUDED for that outcome — ROLE_GRANT writes the project IAM binding (SetIamPolicy), ACCESS_GRANT sets the grant ACTIVE (ActivateAccessGrant). A class annotation cannot split by request field, so the gate PR owes a type carve-out. UpdateIssue carries the same debt through allow_missing.

  • RolloutService/CreateRollout and BatchRunTasks stay WRITE, and two ways a change reaches execution without a human approval ride them. They get opposite answers, because only one is fixable at the MCP layer, and both are recorded on the CreateRollout annotation:

    • The gate PR owes a rule. Every approval check on that path is guarded on a linked issue existing — in the handler and in the locked re-check — so an issueless plan executes whatever require_issue_approval says. The rule to implement: on the MCP path, refuse CreateRollout and BatchRunTasks when the project sets require_issue_approval and the plan has no linked approved issue. Not because the behavior is a defect — the shipped GitOps Service Agent role holds no issue permission at all, so issueless deployment is a supported product contract and READ_WRITE preserves supported contracts. Because without it the four FORBIDDEN approval methods are decorative for the change lane: an agent that cannot clear the gate can skip it instead. BOT-71 tracks the product-wide question; if the product closes it, this rule becomes redundant rather than wrong.
    • The gate PR owes nothing on group drift. An approval binds the plan's specs and approval_input_version, not the databases a group target resolves to, and CreateRollout re-resolves the group live. No MCP rule closes it: approving an issue enqueues rollout creation on the auto path with no CreateRollout call, and schema sync enlarges the set with no API call. Only binding approval to a digest of the resolved set works, and live expansion was introduced deliberately (refactor: remove deployment snapshot and simplify plan updates #18589), so that is a product decision. BOT-72.

    CreatePlan stays WRITE with no rule of its own — a plan that is never rolled out changes nothing.

Rows that changed on re-verification of #21141

  • WorksheetService (9 methods) is gone; SavedQueryService (11) replaced it. ListSavedQueries inherits ListWorksheets' exclusion; SearchSavedQueries and SearchSavedQueryFolders are caller-scoped and READ; the GetSavedQueryPolicy/SetSavedQueryPolicy pair is EXCLUDED as access control, decided together.
  • InstanceService/ListInstanceDatabase moves out of the read-only set. Its inline-instance branch dials a caller-supplied connection config, and even the stored branch opens an admin connection under the same bb.instances.get that GetInstance uses for a pure store read.
  • IdentityProviderService/ListIdentityProviders moves out. With an empty parent it lists across workspaces, and each OIDC entry triggers a server-side fetch of the issuer's well-known document.
  • ProjectService/CreateProject moves to EXCLUDED. Every other project-lifecycle RPC is EXCLUDED under a reason whose text names "project lifecycle", and create persists the same governance switches update is excluded for — allow_self_approval, require_issue_approval — so a read-write session could have built itself a project with the review gate off.
  • SubscriptionService/GetSubscription and ListPurchasePlans move to EXCLUDED, matching the rest of billing. Licence tier stays readable through GetActuatorInfo.
  • The 16 methods feat(mcp): AI access capability vocabulary, resolution and CI lint (1b-1) #21141 shipped as ai.op.* operations resolve to plain classes; no identifier is minted.

Also here

backend/api/mcp/tool_change.go told the agent its next action was APPROVE_ISSUE — a call this PR refuses. It says AWAIT_HUMAN_APPROVAL, and the issue link the tool already returns is what a human follows.

Out of scope, found while classifying

Independent of the ceiling, each reachable by a human through the console today. Worth their own issues:

  1. Project reads return the webhook URL unredacted while audit redacts it; instance reads return MariaDB account password hashes in InstanceRole.Attribute; GetCurrentUser returns the temp MFA secret. These are the eight RETURNS_A_STORED_SECRET rows.
  2. CreateIssue with type=ROLE_GRANT writes a project IAM binding with no approver when no template matches, behind plain bb.issues.create, for a grantee the request names.
  3. AccessGrantService/ActivateAccessGrant sets a grant ACTIVE without reading the linked issue's approval state.
  4. OrgPolicyService/ListPolicies returns the MASKING_EXEMPTION payload gated on bb.policies.list alone, bypassing the per-type permission GetPolicy enforces.
  5. AIService/Chat declares auth_method = CUSTOM but performs no authorization, so every authenticated workspace user can spend the stored provider key.
  6. ListInstanceDatabase's inline branch skips checkInstanceDataSources/validateExternalSecretForSaaS, which CreateInstance runs.
  7. UpdateDatabaseCatalog ignores update_mask, so a partial request wipes every classification not restated.
  8. SettingService/GetSetting substitutes per-name permissions that WorkspaceMemberRole holds, making the sign-in policy and the MCP switch readable by every member.
  9. ListSavedQueries, GetSavedQuery and TestWebhook carry audit = false while reading cross-user SQL or posting into customer chat.
  10. Stale proto comments: ExportRequest.admin, GetIssueRequest.force and ListInstanceRolesRequest.refresh are dead fields; ListPlansRequest.parent's projects/- wildcard returns zero rows; GetDatabaseMetadataRequest.filter promises a masking level that does not exist; CheckRelease's comment omits its side effects.
  11. require_issue_approval is enforced only when a plan has a linked issue, at all four check sites — so an issueless rollout skips it, and creating one first makes approval finding refuse afterwards. The question is whether the setting should mean "every rollout is approved" or keep meaning "if there is an issue, it must be approved" (BOT-71).
  12. An approval is not bound to the resolved target set of a database-group plan, so databases joining the group after approval execute under it — and under plan checks that never ran against them (BOT-72).

Gates

buf format / lint / generate; gofmt; golangci-lint ./backend/api/... clean; go test ./backend/api/v1/ ./backend/api/mcp/ ./backend/api/auth/... and ./backend/tests -run '^TestMCP'; server build; frontend type-check.

vsai12 added 5 commits August 14, 2026 13:18
… (1b-1)

Every v1 RPC now carries mcp_method_class. 183 methods were unannotated; the
25 that already carried FORBIDDEN are unchanged.

READ 48 / WRITE 40 / EXCLUDED 91 / FORBIDDEN 29.

Only FORBIDDEN is enforced. READ and WRITE are inert until the gate ships, so
the annotation states where a method belongs, not where the boundary sits. The
enum comment says so.

Two additions to the vocabulary:

- EXCLUDED, for methods no phase-1 mode serves. Workspace administration, plus
  the handful that do something materially worse than the plain permission they
  share. It is not FORBIDDEN because it is reversible: an admin-capable ceiling
  could serve these, and none of them breaks the MCP boundary itself.
- mcp_exclusion_reason, required on every EXCLUDED row, so an exclusion nobody
  wrote down cannot happen.

Four methods are newly FORBIDDEN and therefore newly denied on merge:
ApproveIssue, RejectIssue, RequestIssue and RetryIssueApproval, under the new
DRIVES_THE_APPROVAL_DECISION reason. An agent may compose a change and may
execute an approved one; it does not approve its own work.

The lint asserts, over the compiled descriptors: every RPC carries a class and
UNSPECIFIED fails the build; a FORBIDDEN method carries a reason and every
reason in use has wording; an EXCLUDED method carries a reason; and no denied
class is reachable through any serving mode. Each clause is a function over
rows with a RED test that mutates one input and asserts it fires.

testdata/mcp_method_classification.md renders the annotations for review. The
annotations are the source of truth; regenerate the file with
MCP_INVENTORY=write go test ./backend/api/v1/ -run TestMCPClassificationInventory
…r rows

Pre-review findings.

The third lint clause could not fail. It held every FORBIDDEN and EXCLUDED row
against a serving table that lists only READ and WRITE, so the contradiction it
looked for was one a single-valued annotation already makes impossible — it lint
ed the test's own constant. Replaced with the check that can drift: read both
enums out of the compiled descriptors and assert every class is served by some
mode or denied, never both and never neither, that every ceiling mode says what
it serves, and that no row carries a class the tables skipped. Add a fifth class
or a fourth mode and CI fails until somebody decides who serves it. Four RED
tests, one per way it can break.

CreateProject moves to EXCLUDED. Every other project-lifecycle RPC is EXCLUDED
under a reason whose own text says "project lifecycle", and create persists the
same governance switches update is excluded for — allow_self_approval,
require_issue_approval — so a read-write session could have built itself a
project with the review gate off. It also could not read back what it created.

GetSubscription moves to EXCLUDED, matching the rest of billing. Licence tier is
still readable through GetActuatorInfo.

Query stays READ and CreateIssue stays WRITE, both with the debt written where
the gate PR will look. Query is decided by its argument, not by the method: it
authorizes DML and DDL per statement on every engine with the new ACL, so the
serving PR owes a statement clamp. CreateIssue's ROLE_GRANT and ACCESS_GRANT
types complete on creation and write a project IAM binding, which a class
annotation cannot split off, so that PR owes a type carve-out.

The approval rationale claimed containment it does not buy. UpdatePlan and
UpdateIssue reset approval findings and stay WRITE, deliberately — editing a
proposal is the agent's job — and a plan created without an issue reaches
execution with no approval at all. The reason now claims only what holds: an
agent never casts the vote on its own change.

Also: the MCP change tool told the agent its next action was APPROVE_ISSUE, a
call this series now refuses; it says AWAIT_HUMAN_APPROVAL. EXCLUDED joins the
interceptor's dispatch test, since 91 methods must keep being served. The
interceptor doc no longer describes a rollout this PR finished. Two RED tests
for the cross-reason branches, and the wording check that TestForbiddenClass
Membership duplicated is gone.
…he lint

Recheck of the fix delta.

Narrowing DRIVES_THE_APPROVAL_DECISION to "records the review decision" cut the
half that covered RetryIssueApproval, which records nothing — and that sentence
is the denial the agent is shown, so the refusal would have stated a mechanism
the file's own comment denies fifteen lines later. The reason now says what all
four do: works the approval step that gates the change, by recording the
decision or by re-running the finding that sets it. RetryIssueApproval belongs
under the second half — on an auto-approved result the same call activates the
grant and enqueues the rollout, so it moves an issue through the gate with no
human acting.

The serving-decision lint's last loop was dead: `decided` was set for every
member of the enum, including one the tables had just failed to decide, so it
could only fire on a class value no descriptor can produce. It now holds the
classes that came out with exactly one answer, which makes dropping a class from
the serving table report every method annotated with it rather than the class
alone — the shape of the damage. Its RED test asserts that. Vocabulary
membership moved to its own set so a doubly-decided class does not also get told
it is not a class.

The CreateIssue debt comment named the ROLE_GRANT mechanism for both types.
ACCESS_GRANT writes no IAM binding; it sets the grant ACTIVE, which is what
AccessGrantService/ActivateAccessGrant is excluded for. Both are named now.

The removed duplicate wording check left its comment dangling at the end of a
function body; it sits on the assertion it describes.
Recheck round two. Making `decided` an exclusive-or repaired the dead loop and
broke the other branch: a class both served and denied came out undecided, so
seeding FORBIDDEN into READ_WRITE produced 29 lines telling you AuthService/Login
has no serving decision — which is false, and which sorts above the one line that
names the real problem. It is an inclusive-or; a class with two decisions is
reported once, at class level. The RED test for that branch now passes the real
208 rows, which is what let it through.

Also sort the mode list in that message: `serving` is a map, so the violation
text was nondeterministic across runs.
…s the message stable

The inclusive-or fix left its own doc comment describing the exclusive-or it
replaced, which documented the defect as the behavior. It now says `decided`
holds the classes some decision claims, both included.

The sort added so the mode list prints deterministically had no test that could
see it: the one case reaching that message widened a single mode, so the slice
had one element. It widens two now, and the expected string is only stable
because of the sort.

Also: the inline comment pointed at "the class-level loop above" from inside
that loop, and the reflowed paragraph had picked up a second short line.
@vsai12
vsai12 requested review from a team, RainbowDashy, d-bytebase and ecmadao as code owners August 14, 2026 21:15
@cla-bot cla-bot Bot added the cla-signed label Aug 14, 2026
@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest Buf updates on your PR. Results from workflow Proto linter / lint-protos (pull_request).

BuildFormatLintBreakingUpdated (UTC)
✅ passed✅ passed✅ passed⏩ skippedAug 14, 2026, 10:34 PM

vsai12 added 2 commits August 14, 2026 15:04
…ad it

Codex raised two approval gaps on the review. Both mechanisms are real; both are
pre-existing and human-reachable, and neither is a reason to reclassify. What was
missing is that the debt was recorded in the PR body and under the FORBIDDEN
approval reason, not on the rollout annotations themselves — so a reader of
rollout_service.proto asking why CreateRollout is WRITE saw nothing. The PR
already set that precedent for Query and CreateIssue; this applies it here.

Gap one: both approval checks on the rollout path are guarded on a linked issue
existing, in the handler and again in the locked re-check, so an issueless plan
reaches execution regardless of require_issue_approval. Not an MCP hole — the
shipped GitOps Service Agent role holds no issue permission at all and can only
take that path, and the console says so outright ("the project settings are
client-side gates", bypassDeploy.tsx).

Gap two: an approval binds the plan's specs and its approval_input_version, not
the databases a group target resolves to. CreateRollout re-resolves the group
live, and nothing in UpdateDatabaseGroup, UpdateDatabase or schema sync bumps
that version, so databases joining the group after approval execute under it.
The deployment snapshot used to be that binding and was removed deliberately in
#18589, so restoring it is a product decision, not a classification one.

CreatePlan gets a pointer as the step that produces the issueless plan.

Also: say plainly that the gate PR must lift mcpServingModes rather than declare
a second copy — two copies would let this lint stay green while the runtime
serving rules drift, which is the one thing a specification-shaped test cannot
catch about itself.
…er MCP can fix it

The three artifacts stated three contracts: the annotation said 1b-2 "owes a
carve-out", the PR body called it 1b-2's decision to take, and BOT-71/72 framed
both as product-wide. Codex is right that a security-critical gate PR cannot be
handed that.

The ambiguity was mine and it was sharper than stated: the two gaps get OPPOSITE
answers, because only one is fixable at the MCP layer, and one comment covered
both.

Issueless rollout — the gate PR OWES A RULE, now stated exactly: on the MCP
path, refuse CreateRollout and BatchRunTasks when the project sets
require_issue_approval and the plan has no linked approved issue. The
justification changed too. "Human-reachable and pre-existing" does not dismiss
an MCP restriction — this PR excludes eight human-reachable rows — so the real
reason WRITE stays is that issueless deployment is a supported product contract
(the GitOps Service Agent role holds no issue permission at all) and READ_WRITE
preserves supported contracts. The rule is owed anyway, because without it the
four FORBIDDEN approval methods are decorative for the change lane: an agent
that cannot clear the gate skips it instead.

Group drift — the gate PR owes NOTHING, and must not pretend otherwise. No MCP
rule closes it: approval enqueues rollout creation on the auto path with no
CreateRollout call, and schema sync enlarges the set with no API call. Only an
approval-time digest of the resolved set works, and live expansion was
deliberate (#18589), so it is a product decision. BOT-72 alone.

CreatePlan needs no rule of its own — a plan never rolled out changes nothing.

Dropped the bypassDeploy.tsx quote as load-bearing evidence: the backend does
enforce approval for linked issues, including the transactional recheck, so
"only enforces bb.rollouts.create" overstates it. The GitOps role is the
evidence.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 46661dc640

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread proto/v1/v1/issue_service.proto Outdated
vsai12 added 2 commits August 14, 2026 15:23
Codex is right about the mechanism, and my comment stated the opposite of it.
ApproveIssue and RejectIssue record the decision — canReview for an approver
role, the self-approval guard, an APPROVED/REJECTED approver appended.
RequestIssue is structurally the reverse: it requires the issue to be ALREADY
rejected, requires the actor to be the CREATOR, never calls canReview, and
records no decision — it strips the REJECTED approvers and returns the issue to
PENDING for a fresh human decision.

So it approves nothing, and forbidding it protects nothing the other two do not.
The cost is concrete: while a rejection stands both Approve and Reject hard-fail,
so this is the only exit from the rejected state, and an agent that fixes what a
human asked it to fix cannot resubmit.

Grouping it with approve/reject was the error the file itself warns about — a
denial whose stated reason has drifted from the mechanism. The comment now says
what each action does, and marks RequestIssue as the row to widen. The
classification is unchanged pending the lead's call, since the four-method set
is a spec-level decision.
… exit from rejected

Spec §1b-1 named four approval methods as FORBIDDEN. That line grouped by RPC
family; the mechanism does not agree, and the mechanism is what the reason
annotation is required to describe.

ApproveIssue and RejectIssue record the review decision: applyReviewAction
requires an approver role via canReview, enforces the self-approval guard, and
appends an APPROVED or REJECTED approver. RequestIssue is the reverse — it
requires the issue to be already rejected, requires the actor to be the CREATOR,
never calls canReview, and records no decision. It strips the REJECTED approvers
and returns the issue to PENDING for a fresh human decision.

So forbidding it protected nothing the other two do not already protect, and it
cost the loop propose_database_change exists for: while a rejection stands both
Approve and Reject hard-fail, so this is the only exit from that state. An agent
that fixed the SQL a human asked it to fix could not resubmit — the human had to
re-request from the console, or the agent had to abandon the issue and create a
replacement.

The self-approval guard is untouched. A human approval is still required, and
ApproveIssue, RejectIssue and RetryIssueApproval stay FORBIDDEN; the last of
those can auto-complete a grant and enqueue a rollout with no human acting,
which this one cannot.

FORBIDDEN 29 -> 28, WRITE 39 -> 40. Found by the Codex review; Vincent took the
call.
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant