What problem does this solve?
Gateway mode is a single deployment-wide switch. gateway.type from
blue.gateway.yaml / HARNESS_GATEWAY_TYPE becomes the top-level gateway block via
apply_deployment_gateway_policy (services/control-api/src/lib.rs:718), and from there
every allowed harness for every user is routed through the gateway. The docs state the
limit outright: "The mode is selected once for the deployment policy and applies to every
allowed coding agent" (apps/docs/next/concepts/gateway-mode.mdx).
Two things operators cannot express today:
- Exclude a harness. An org that routes Kimi and OpenCode through its LiteLLM gateway
still has to route Claude Code and Codex through it. Teams that hold Anthropic or OpenAI
enterprise seats want those two left on their own credentials and untouched by the proxy,
while everything else stays governed. It is all four or none.
- Exclude a user. There is no way to keep one person off the gateway — a contractor on
their own key, or a staged rollout where the gateway is enabled for the deployment but
only a pilot group should route through it.
The only existing off-switch is the client's /direct command, which persists
force_governance_only in blue.toml (crates/gh-cli/src/commands.rs:3452). That is
per-workstation, user-controlled, and invisible to the server — an administrator cannot use
it to exclude a harness or a person, and a user can always turn it back off.
Enabling the gateway and then excluding parts of it is also not expressible in YAML:
governance_seed_yaml (:2170) strips governance.gateway from the deploy document and
re-inserts the deployment policy, and apply_deployment_gateway_policy overwrites
config.gateway on every admin save (:6240).
Proposed solution
Two subtractive overrides on top of the existing switch. Neither can ever enable gateway
mode — that stays the server's sole authority, and the "client may only downgrade"
invariant is preserved and extended to the policy layer.
Effective precedence, all subtractive:
deployment gateway absent > client too old > per-user exclusion > per-harness policy > /direct.
A. Per-harness, org-level — a configuration control
Add gateway_disabled: Option<bool> to HarnessPolicy (crates/gh-service/src/schema.rs:113).
Absent/false inherits the deployment gateway.
- Not named
gateway: reject_extension_owned_yaml (:6149) already rejects
harnesses.<h>.gateway as "a global policy", with a test at :10857.
- Not inside
GatewayConfig: it is #[serde(deny_unknown_fields)] (schema.rs:492), so a
new field there makes every installed CLI fail to deserialize /governance-config —
fatal, with no cache fallback (crates/gh-service/src/client.rs:209). HarnessPolicy
ignores unknown fields, so old clients keep parsing.
- Serialize it explicitly rather than
skip_serializing_if: un-excluding must produce
false, not an absent key, because merge_governance_value (:2690) three-way-merges the
deploy baseline against the stored document and "deleted here, absent there" is ambiguous.
- Owner:
PUT /admin/harnesses/:harness/managed-config (:7814) — already per-harness,
already does optimistic concurrency, and does not call apply_deployment_gateway_policy.
Not /admin/governance-extensions, whose replace_extensions (:7967) is about
packages/MCP.
- Client: pass
gateway: None for an excluded harness at crates/gh-agent/src/lib.rs:234
and :242. WriteOptions.gateway_enabled (commands.rs:57) stays exactly as it is — it is
the /direct override and is already separate from the deployment signal.
B. Per-user — a security control, enforced server-side
New table gateway_user_exclusions(organization_id, user_id, harness text NULL, created_by, created_at), harness IS NULL meaning a full exclusion. Validation mirrors
validate_package_audiences (:7995), including its nuance of rejecting newly added
inactive users while retaining already-present ones — otherwise suspending a user makes
every later governance save fail.
Enforced at three points, in this order of importance:
resolve_gateway_key (:4288) — the only real chokepoint. It currently checks
deployment settings, a live gateway_auth_sessions row, and expiry, and never reads the
governance document. Without a check here an excluded user keeps working until their
inference JWT expires — up to 12 h (gateway_inference_token_ttl_seconds,
deploy/blue.gateway.yaml:17). Cache the lookup on (user_id, revision) and invalidate
over the existing gateway_cache_events channel.
ensure_managed_gateway_key (:3844) — return the existing enabled: false shape, which
gateway_access_requires_provisioning (commands.rs:1156) already treats as
authoritative. Otherwise POST /gateway/key/ensure (:4169) stays callable by the
excluded user under their own governance:read scope.
personalize_gateway_config (:4988) — strip config.gateway and skip minting.
Ordering point 3 before the mint is load-bearing: renew_gateway_auth_session
(services/control-api/src/gateway_auth.rs:40) deliberately reactivates revoked sessions, and
it is reached only from mint_gateway_inference_token. Make that an explicitly tested
invariant or an exclusion un-does itself on the next poll.
On transition into exclusion, revoke the user's gateway_auth_sessions rows so running
agents are cut in seconds rather than in ≤12 h, and mint a governance revision so
/governance-config/events (:5140) fans out — it streams only revision signals, so a
change that mints no revision is invisible until the 300 s TTL. Retain the upstream key
rather than deleting the gateway_key_selections row, so un-excluding preserves
source_key_hash and request-log profile_id continuity.
Old clients
Do not bump CONTRACT_VERSION. stamp_version_aware_client_floor (:8064) sets
contract_version = CONTRACT_VERSION for every gateway deployment (:8107, gated only on
config.gateway.is_some()), so bumping it to 4 426s every installed CLI on every gateway
tenant whether or not they use this feature.
Instead, personalize_gateway_config should strip gateway entirely for a client that does
not advertise a gateway_harness_scope capability — an old CLI degrades to governance-only
instead of silently ignoring an exclusion. Fail-closed, and no new blocking capability
slug; #101 is already about required_capabilities failures producing unreadable errors.
Surfaces
gateway_status (:3571) maps every allowed_harnesses entry to the gateway kind
(:3580). GatewayHarnessStatus needs a disabled_by: "policy" | "user" | null. Keep
enabled deployment-scoped — gateway/page.tsx:159 and dashboard-sidebar.tsx:104 gate on
it, and an excluded admin still needs the request-logs page.
- Client sites that must become per-harness: the 8 gateway predicate sites in
commands.rs
should collapse into one helper. Seven spell out
config.gateway.is_some() && !force_governance_only (2173, 2684, 2836, 2849, 2901, 3592, 3672); the remaining two are already inconsistent with them — gateway_available (:2920)
omits the /direct term, and gateway_token_expires_at (:2892) has no predicate at all,
so an excluded harness would still show the expiring-token countdown for a token it never
received.
AppliedState.gateway_enabled (commands.rs:1528) is one bool for the whole state file,
written for the selected harness. Move it into the per-harness maps and bump
APPLIED_STATE_SCHEMA_VERSION.
- Configs are written one harness at a time (
apply_loaded scopes allowed_harnesses to the
target, :3516), so an excluded harness keeps its stale gateway base_url on disk until it
is next launched. Harmless — the token is launch-scoped and never written to disk — but it
should be stated rather than discovered.
- Contract:
deploy/contract/governance.openapi.yaml and apps/docs/openapi/next.yaml must
stay byte-identical (apps/docs/scripts/check-contract.mjs).
- Docs under
apps/docs/next/: concepts/gateway-mode.mdx (contains the sentence this
falsifies), reference/governance-config.mdx, deployment/blue-yaml.mdx,
admin/gateway-access.mdx, admin/user-management.mdx, cli/commands.mdx. Leave
apps/docs/0.1.0/ alone — it is a frozen snapshot checked by check-release-snapshot.mjs.
Scope note
A is a small change confined to one struct, one admin endpoint, one dashboard editor and one
gh-agent loop. B touches a new table, three enforcement points, and the credential/session
revocation lifecycle. Best delivered as two PRs against this issue, A first; happy to split
into two issues if the team prefers. Both involve dashboard surfaces, so they need the
core-team design review CONTRIBUTING requires.
Framing: A is a configuration control — enforcement is client-side, and
gateway_request_logs.harness comes from the untrusted x-harness-agent header
(services/inference-proxy/src/main.rs:1431), so it cannot prove a harness-level bypass. B is
a security control, enforced at resolve_gateway_key.
Acceptance criteria
Contract
Per-harness
Per-user
Validation
Tests
Before submitting
What problem does this solve?
Gateway mode is a single deployment-wide switch.
gateway.typefromblue.gateway.yaml/HARNESS_GATEWAY_TYPEbecomes the top-levelgatewayblock viaapply_deployment_gateway_policy(services/control-api/src/lib.rs:718), and from thereevery allowed harness for every user is routed through the gateway. The docs state the
limit outright: "The mode is selected once for the deployment policy and applies to every
allowed coding agent" (
apps/docs/next/concepts/gateway-mode.mdx).Two things operators cannot express today:
still has to route Claude Code and Codex through it. Teams that hold Anthropic or OpenAI
enterprise seats want those two left on their own credentials and untouched by the proxy,
while everything else stays governed. It is all four or none.
their own key, or a staged rollout where the gateway is enabled for the deployment but
only a pilot group should route through it.
The only existing off-switch is the client's
/directcommand, which persistsforce_governance_onlyinblue.toml(crates/gh-cli/src/commands.rs:3452). That isper-workstation, user-controlled, and invisible to the server — an administrator cannot use
it to exclude a harness or a person, and a user can always turn it back off.
Enabling the gateway and then excluding parts of it is also not expressible in YAML:
governance_seed_yaml(:2170) stripsgovernance.gatewayfrom the deploy document andre-inserts the deployment policy, and
apply_deployment_gateway_policyoverwritesconfig.gatewayon every admin save (:6240).Proposed solution
Two subtractive overrides on top of the existing switch. Neither can ever enable gateway
mode — that stays the server's sole authority, and the "client may only downgrade"
invariant is preserved and extended to the policy layer.
Effective precedence, all subtractive:
deployment gateway absent>client too old>per-user exclusion>per-harness policy>/direct.A. Per-harness, org-level — a configuration control
Add
gateway_disabled: Option<bool>toHarnessPolicy(crates/gh-service/src/schema.rs:113).Absent/
falseinherits the deployment gateway.gateway:reject_extension_owned_yaml(:6149) already rejectsharnesses.<h>.gatewayas "a global policy", with a test at:10857.GatewayConfig: it is#[serde(deny_unknown_fields)](schema.rs:492), so anew field there makes every installed CLI fail to deserialize
/governance-config—fatal, with no cache fallback (
crates/gh-service/src/client.rs:209).HarnessPolicyignores unknown fields, so old clients keep parsing.
skip_serializing_if: un-excluding must producefalse, not an absent key, becausemerge_governance_value(:2690) three-way-merges thedeploy baseline against the stored document and "deleted here, absent there" is ambiguous.
PUT /admin/harnesses/:harness/managed-config(:7814) — already per-harness,already does optimistic concurrency, and does not call
apply_deployment_gateway_policy.Not
/admin/governance-extensions, whosereplace_extensions(:7967) is aboutpackages/MCP.
gateway: Nonefor an excluded harness atcrates/gh-agent/src/lib.rs:234and
:242.WriteOptions.gateway_enabled(commands.rs:57) stays exactly as it is — it isthe
/directoverride and is already separate from the deployment signal.B. Per-user — a security control, enforced server-side
New table
gateway_user_exclusions(organization_id, user_id, harness text NULL, created_by, created_at),harness IS NULLmeaning a full exclusion. Validation mirrorsvalidate_package_audiences(:7995), including its nuance of rejecting newly addedinactive users while retaining already-present ones — otherwise suspending a user makes
every later governance save fail.
Enforced at three points, in this order of importance:
resolve_gateway_key(:4288) — the only real chokepoint. It currently checksdeployment settings, a live
gateway_auth_sessionsrow, and expiry, and never reads thegovernance document. Without a check here an excluded user keeps working until their
inference JWT expires — up to 12 h (
gateway_inference_token_ttl_seconds,deploy/blue.gateway.yaml:17). Cache the lookup on(user_id, revision)and invalidateover the existing
gateway_cache_eventschannel.ensure_managed_gateway_key(:3844) — return the existingenabled: falseshape, whichgateway_access_requires_provisioning(commands.rs:1156) already treats asauthoritative. Otherwise
POST /gateway/key/ensure(:4169) stays callable by theexcluded user under their own
governance:readscope.personalize_gateway_config(:4988) — stripconfig.gatewayand skip minting.Ordering point 3 before the mint is load-bearing:
renew_gateway_auth_session(
services/control-api/src/gateway_auth.rs:40) deliberately reactivates revoked sessions, andit is reached only from
mint_gateway_inference_token. Make that an explicitly testedinvariant or an exclusion un-does itself on the next poll.
On transition into exclusion, revoke the user's
gateway_auth_sessionsrows so runningagents are cut in seconds rather than in ≤12 h, and mint a governance revision so
/governance-config/events(:5140) fans out — it streams only revision signals, so achange that mints no revision is invisible until the 300 s TTL. Retain the upstream key
rather than deleting the
gateway_key_selectionsrow, so un-excluding preservessource_key_hashand request-logprofile_idcontinuity.Old clients
Do not bump
CONTRACT_VERSION.stamp_version_aware_client_floor(:8064) setscontract_version = CONTRACT_VERSIONfor every gateway deployment (:8107, gated only onconfig.gateway.is_some()), so bumping it to 4 426s every installed CLI on every gatewaytenant whether or not they use this feature.
Instead,
personalize_gateway_configshould stripgatewayentirely for a client that doesnot advertise a
gateway_harness_scopecapability — an old CLI degrades to governance-onlyinstead of silently ignoring an exclusion. Fail-closed, and no new blocking capability
slug; #101 is already about
required_capabilitiesfailures producing unreadable errors.Surfaces
gateway_status(:3571) maps everyallowed_harnessesentry to the gateway kind(
:3580).GatewayHarnessStatusneeds adisabled_by: "policy" | "user" | null. Keepenableddeployment-scoped —gateway/page.tsx:159anddashboard-sidebar.tsx:104gate onit, and an excluded admin still needs the request-logs page.
commands.rsshould collapse into one helper. Seven spell out
config.gateway.is_some() && !force_governance_only(2173, 2684, 2836, 2849, 2901, 3592, 3672); the remaining two are already inconsistent with them —gateway_available(:2920)omits the
/directterm, andgateway_token_expires_at(:2892) has no predicate at all,so an excluded harness would still show the expiring-token countdown for a token it never
received.
AppliedState.gateway_enabled(commands.rs:1528) is one bool for the whole state file,written for the selected harness. Move it into the per-harness maps and bump
APPLIED_STATE_SCHEMA_VERSION.apply_loadedscopesallowed_harnessesto thetarget,
:3516), so an excluded harness keeps its stale gatewaybase_urlon disk until itis next launched. Harmless — the token is launch-scoped and never written to disk — but it
should be stated rather than discovered.
deploy/contract/governance.openapi.yamlandapps/docs/openapi/next.yamlmuststay byte-identical (
apps/docs/scripts/check-contract.mjs).apps/docs/next/:concepts/gateway-mode.mdx(contains the sentence thisfalsifies),
reference/governance-config.mdx,deployment/blue-yaml.mdx,admin/gateway-access.mdx,admin/user-management.mdx,cli/commands.mdx. Leaveapps/docs/0.1.0/alone — it is a frozen snapshot checked bycheck-release-snapshot.mjs.Scope note
A is a small change confined to one struct, one admin endpoint, one dashboard editor and one
gh-agentloop. B touches a new table, three enforcement points, and the credential/sessionrevocation lifecycle. Best delivered as two PRs against this issue, A first; happy to split
into two issues if the team prefers. Both involve dashboard surfaces, so they need the
core-team design review CONTRIBUTING requires.
Framing: A is a configuration control — enforcement is client-side, and
gateway_request_logs.harnesscomes from the untrustedx-harness-agentheader(
services/inference-proxy/src/main.rs:1431), so it cannot prove a harness-level bypass. B isa security control, enforced at
resolve_gateway_key.Acceptance criteria
Contract
CONTRACT_VERSIONstays3; no fixture,deploy/*.yaml, or docs page changes it.gateway_harness_scopegetsgateway: null, no provisioning,no minted JWT, and launches in governance-only — not a 426 and not a serde error.
HarnessPolicyfield still deserializes on an old client.deploy/contract/governance.openapi.yamlandapps/docs/openapi/next.yamlmatch.Per-harness
gateway_disabled: trueon one harness: no gateway wiring written for it, every otherallowed harness unchanged.
/admin/harnesses/:harness/managed-config,/admin/governance-extensions,/admin/governance-config, and a control-api restart.apply_deployment_gateway_policydoes not clear it./directis hidden for an excluded harness and no token countdown appears.gateway_status.harnessesflags exclusions;gateway_status.enabledstaysdeployment-scoped.
crates/gh-config/tests/golden/are unchanged — an excludedharness renders the existing
governanceplan.Per-user
/governance-confighasgateway: null, with no provisioning and nosession renewal.
POST /internal/gateway/resolvedenies an excluded user holding a valid unexpired JWTand a live session row.
POST /gateway/key/ensurereturnsenabled: falseand makes no provisioner call.profile_idcontinuity held)./governance-config/events.governance:read-scoped endpoint lets a user un-exclude themselves.Validation
supported_harnesspath.still saveable.
Tests
lib.rstests: stamping with exclusions,validate_complete_governancematrix,personalize_gateway_configstripping,resolve_gateway_keydenial. The existingreject_extension_owned_yamlassertion at:10857must still pass.tests/e2e/specs/journey.spec.ts:180gains a sibling: exclude codex, assert no gatewaybase_urlin.codex/blue.config.toml, assert/directis absent.tests/e2e/specs/contract-surface.spec.tsgains the excluded-user 403.Before submitting