diff --git a/apps/docs/next/deployment/blue-yaml.mdx b/apps/docs/next/deployment/blue-yaml.mdx
index b941f0c..e350843 100644
--- a/apps/docs/next/deployment/blue-yaml.mdx
+++ b/apps/docs/next/deployment/blue-yaml.mdx
@@ -305,6 +305,7 @@ The `governance` section is the deployment-authored baseline delivered to client
| `contract_version` | Wire contract version. Current clients support version 3. |
| `required_capabilities` | Features a client must support before accepting policy. |
| `minimum_client_version` | Operator-visible rollout floor; required when using harness version ranges or versioned package adapters. |
+| `required_client_version` | Optional canonical exact SemVer recommendation for the Blue CLI. Defaults to the control API deployment version and requires `tenant_client_version_pin`. |
| `ttl_seconds` | Client cache and polling interval; defaults to 300 seconds. |
| `required` | When true, clients fail closed instead of launching from stale cached policy. |
| `allowed_harnesses` | Supported agent keys users may launch through Blue. |
@@ -402,3 +403,19 @@ Every source must be immutable and SHA-256 pinned. Use `platform_sources` when h
A complete rollout has healthy service probes, no unresolved environment references, the expected governance revision in the dashboard, and matching desired/applied revisions on a test client.
+
+## Recommended Blue CLI release
+
+The reference control API advertises its deployment version as the exact
+recommended Blue CLI release. Set
+`governance.required_client_version` to a published, compatible release to
+override that default, for example during a rolling upgrade. The default is
+applied when serving a document, so old revisions follow new deployments without
+being rewritten. An explicit value stays fixed across rollouts. Use canonical
+SemVer without a `v` prefix or range operators. `minimum_client_version` remains
+informational.
+
+The `tenant_client_version_pin` capability is required whenever a pin is served.
+The `X-Blue-Required-Client-Version` header exposes the same pin on successful
+config responses and capability/contract rejections (HTTP 426). Older clients
+without this capability cannot consume the document.
diff --git a/apps/docs/next/reference/governance-config.mdx b/apps/docs/next/reference/governance-config.mdx
index 4d612b4..019449c 100644
--- a/apps/docs/next/reference/governance-config.mdx
+++ b/apps/docs/next/reference/governance-config.mdx
@@ -79,6 +79,7 @@ harnesses:
| `allowed_harnesses` | Harness names permitted by policy. |
| `harnesses` | Per-harness policy keyed by supported harness name. |
| `minimum_client_version` | Operator-visible rollout floor; it does not replace capability enforcement. |
+| `required_client_version` | Optional canonical exact SemVer recommendation for the Blue CLI. Defaults to the control API deployment version and requires `tenant_client_version_pin`. |
| `packages` | Organization-selected, immutable extension packages and their harness adapters. |
| `gateway` | Global inference routing policy applied to every allowed coding agent. |
| `session_upload` | Global portable-session capture policy; compatible profiles upload integrity-checked bundles while older clients remain accepted as non-resumable raw artifacts. |
@@ -138,3 +139,16 @@ Standalone skills can live anywhere in an archive and do not require a native pl
The dashboard can inspect `github:owner/repository@ref` for a public GitHub repository, a public HTTPS archive, or a deployment-managed GitHub/Bitbucket connection. Public GitHub refs resolve to immutable codeload URLs; clients download public sources directly and verify their exact digest. Managed repositories are resolved to an immutable commit and mirrored into organization-scoped object storage. Governance contains an `artifact_id` and exact digest; clients use their existing harness session to request a fresh short-lived download and never receive repository credentials.
Removing a package disables it for subsequent governed launches. Unmodified owned content is deleted; locally changed content is moved to quarantine and reported as drift.
+
+## Recommended Blue CLI release
+
+The reference control API advertises its deployment version as the exact
+recommended Blue CLI release. Set
+`governance.required_client_version` to a published, compatible release to
+override that default. The value remains an exact canonical SemVer, not a range.
+`minimum_client_version` remains informational.
+
+The `tenant_client_version_pin` capability is required whenever a pin is served.
+The `X-Blue-Required-Client-Version` header exposes the same pin on successful
+config responses and capability/contract rejections (HTTP 426). Older clients
+without this capability cannot consume the document.
diff --git a/apps/docs/openapi/next.yaml b/apps/docs/openapi/next.yaml
index f973c2b..051db45 100644
--- a/apps/docs/openapi/next.yaml
+++ b/apps/docs/openapi/next.yaml
@@ -188,12 +188,21 @@ paths:
responses:
"200":
description: The governance config
+ headers:
+ X-Blue-Required-Client-Version:
+ description: Exact recommended Blue CLI release, identical to required_client_version in the document.
+ schema: { type: string }
content:
application/json:
schema: { $ref: "#/components/schemas/GovernanceConfig" }
"403": { description: Authenticated email has no provisioned gateway account }
"409": { description: Gateway key provisioning is required }
- "426": { description: Client contract version or capabilities are insufficient }
+ "426":
+ description: Client contract version or capabilities are insufficient
+ headers:
+ X-Blue-Required-Client-Version:
+ description: Exact recommended Blue CLI release, provided even when the document cannot be consumed.
+ schema: { type: string }
/harness-metadata:
get:
@@ -1684,8 +1693,12 @@ components:
contract_version: { type: integer, default: 1, description: Governance wire contract version }
required_capabilities:
type: array
- items: { type: string, enum: [adapter_intervals, compiled_harness_registry, transactional_reconcile, unverified_harness_versions, versioned_state] }
+ items: { type: string, enum: [adapter_intervals, compiled_harness_registry, tenant_client_version_pin, transactional_reconcile, unverified_harness_versions, versioned_state] }
minimum_client_version: { type: string, description: Operator-visible rollout floor; required capabilities enforce compatibility }
+ required_client_version:
+ type: string
+ description: Canonical exact SemVer of the recommended Blue CLI release. Defaults to the control API deployment version. Requires tenant_client_version_pin capability.
+ example: "0.1.0"
ttl_seconds: { type: integer, description: Client cache TTL (default 300), example: 300 }
required: { type: boolean, description: "If true, client fails closed without fresh config" }
allowed_harnesses:
diff --git a/crates/gh-agent/src/lib.rs b/crates/gh-agent/src/lib.rs
index 7a557de..52e4c0b 100644
--- a/crates/gh-agent/src/lib.rs
+++ b/crates/gh-agent/src/lib.rs
@@ -475,6 +475,7 @@ mod tests {
contract_version: GovernanceConfig::CONTRACT_VERSION,
required_capabilities: Vec::new(),
minimum_client_version: None,
+ required_client_version: None,
ttl_seconds: None,
allowed_harnesses: vec!["codex".into(), "claude".into()],
harnesses: Default::default(),
diff --git a/crates/gh-cli/src/commands.rs b/crates/gh-cli/src/commands.rs
index 5dd1a19..34b122b 100644
--- a/crates/gh-cli/src/commands.rs
+++ b/crates/gh-cli/src/commands.rs
@@ -5320,6 +5320,7 @@ mod tests {
contract_version: GovernanceConfig::CONTRACT_VERSION,
required_capabilities: Vec::new(),
minimum_client_version: None,
+ required_client_version: None,
ttl_seconds: None,
allowed_harnesses: vec!["codex".to_owned()],
harnesses: Default::default(),
diff --git a/crates/gh-service/src/schema.rs b/crates/gh-service/src/schema.rs
index 0f9e372..7d9c066 100644
--- a/crates/gh-service/src/schema.rs
+++ b/crates/gh-service/src/schema.rs
@@ -28,6 +28,9 @@ pub struct GovernanceConfig {
/// enforces whether a client may consume the document.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minimum_client_version: Option,
+ /// Exact tenant-recommended Blue release exported by the control plane.
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub required_client_version: Option,
/// Client-side cache TTL. `None` ⇒ operator default (see [`Self::DEFAULT_TTL_SECONDS`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_seconds: Option,
@@ -62,6 +65,7 @@ impl GovernanceConfig {
pub const DEFAULT_TTL_SECONDS: u64 = 300;
pub const CONTRACT_VERSION: u32 = 3;
pub const CAPABILITIES: &'static [&'static str] = &[
+ "tenant_client_version_pin",
"adapter_intervals",
"compiled_harness_registry",
"transactional_reconcile",
@@ -82,6 +86,20 @@ impl GovernanceConfig {
self.harnesses.get(harness)
}
+ pub fn validate_client_version_pin(
+ required: &str,
+ ) -> Result {
+ let version = semver::Version::parse(required).map_err(|_| {
+ gh_common::GhError::config("required_client_version must be canonical exact SemVer")
+ })?;
+ if version.to_string() != required {
+ return Err(gh_common::GhError::config(
+ "required_client_version must be canonical exact SemVer",
+ ));
+ }
+ Ok(version)
+ }
+
pub fn ensure_client_compatible(&self) -> Result<(), String> {
if self.contract_version > Self::CONTRACT_VERSION {
return Err(format!(
diff --git a/deploy/blue.yaml b/deploy/blue.yaml
index e4c3136..d05570c 100644
--- a/deploy/blue.yaml
+++ b/deploy/blue.yaml
@@ -67,6 +67,9 @@ governance:
- gateway_inference_jwt
# Required when harness version ranges or versioned package adapters are used.
minimum_client_version: "0.1.0"
+ # Exact recommended release and major compatibility boundary. Same-major
+ # differences warn and continue; different majors block.
+ # required_client_version: "0.1.0"
ttl_seconds: 300
required: true
allowed_harnesses: [codex, claude, kimi, opencode]
diff --git a/deploy/consumer/blue/blue.yaml b/deploy/consumer/blue/blue.yaml
index 215171b..c822d3d 100644
--- a/deploy/consumer/blue/blue.yaml
+++ b/deploy/consumer/blue/blue.yaml
@@ -68,6 +68,9 @@ governance:
# Required when gateway is enabled.
# - gateway_inference_jwt
minimum_client_version: "0.1.0"
+ # Exact recommended release and major compatibility boundary. Same-major
+ # differences warn and continue; different majors block.
+ # required_client_version: "0.1.0"
ttl_seconds: 300
required: true
allowed_harnesses: [codex, claude, kimi, opencode]
diff --git a/deploy/contract/governance.openapi.yaml b/deploy/contract/governance.openapi.yaml
index f973c2b..051db45 100644
--- a/deploy/contract/governance.openapi.yaml
+++ b/deploy/contract/governance.openapi.yaml
@@ -188,12 +188,21 @@ paths:
responses:
"200":
description: The governance config
+ headers:
+ X-Blue-Required-Client-Version:
+ description: Exact recommended Blue CLI release, identical to required_client_version in the document.
+ schema: { type: string }
content:
application/json:
schema: { $ref: "#/components/schemas/GovernanceConfig" }
"403": { description: Authenticated email has no provisioned gateway account }
"409": { description: Gateway key provisioning is required }
- "426": { description: Client contract version or capabilities are insufficient }
+ "426":
+ description: Client contract version or capabilities are insufficient
+ headers:
+ X-Blue-Required-Client-Version:
+ description: Exact recommended Blue CLI release, provided even when the document cannot be consumed.
+ schema: { type: string }
/harness-metadata:
get:
@@ -1684,8 +1693,12 @@ components:
contract_version: { type: integer, default: 1, description: Governance wire contract version }
required_capabilities:
type: array
- items: { type: string, enum: [adapter_intervals, compiled_harness_registry, transactional_reconcile, unverified_harness_versions, versioned_state] }
+ items: { type: string, enum: [adapter_intervals, compiled_harness_registry, tenant_client_version_pin, transactional_reconcile, unverified_harness_versions, versioned_state] }
minimum_client_version: { type: string, description: Operator-visible rollout floor; required capabilities enforce compatibility }
+ required_client_version:
+ type: string
+ description: Canonical exact SemVer of the recommended Blue CLI release. Defaults to the control API deployment version. Requires tenant_client_version_pin capability.
+ example: "0.1.0"
ttl_seconds: { type: integer, description: Client cache TTL (default 300), example: 300 }
required: { type: boolean, description: "If true, client fails closed without fresh config" }
allowed_harnesses:
diff --git a/services/control-api/src/lib.rs b/services/control-api/src/lib.rs
index 05dc05f..66b5a56 100644
--- a/services/control-api/src/lib.rs
+++ b/services/control-api/src/lib.rs
@@ -5042,19 +5042,61 @@ async fn revoke_current_gateway_session(
Ok(StatusCode::NO_CONTENT)
}
+fn default_client_version(runtime: Option<&str>) -> String {
+ runtime
+ .filter(|version| {
+ gh_service::GovernanceConfig::validate_client_version_pin(version).is_ok()
+ })
+ .unwrap_or(env!("CARGO_PKG_VERSION"))
+ .to_owned()
+}
+
+fn prepare_client_version_pin(
+ config: &mut gh_service::GovernanceConfig,
+ deployment_version: &str,
+) -> Result {
+ let required = config
+ .required_client_version
+ .get_or_insert_with(|| deployment_version.to_owned());
+ gh_service::GovernanceConfig::validate_client_version_pin(required)
+ .map_err(|error| ApiError::internal(error.to_string()))?;
+ let mut headers = HeaderMap::new();
+ headers.insert(
+ "x-blue-required-client-version",
+ required
+ .parse()
+ .map_err(|_| ApiError::internal("invalid client version header"))?,
+ );
+ if !config
+ .required_capabilities
+ .iter()
+ .any(|cap| cap == "tenant_client_version_pin")
+ {
+ config
+ .required_capabilities
+ .push("tenant_client_version_pin".into());
+ }
+ Ok(headers)
+}
+
async fn governance_config(
State(state): State>,
Extension(who): Extension,
headers: HeaderMap,
-) -> Result, ApiError> {
+) -> Result {
let row = current_config(&state.pool, who.organization_id).await?;
let revision = row.revision;
let mut config = serde_json::from_value(row.document)
.map_err(|error| ApiError::internal(format!("decoding stored config: {error}")))?;
- enforce_client_capabilities(&headers, &config)?;
+ let runtime_version = std::env::var("BLUE_DEPLOYMENT_VERSION").ok();
+ let deployment_version = default_client_version(runtime_version.as_deref());
+ let pin_headers = prepare_client_version_pin(&mut config, &deployment_version)?;
+ if let Err(error) = enforce_client_capabilities(&headers, &config) {
+ return Ok((pin_headers, error).into_response());
+ }
personalize_package_config(&state.pool, &revision, who.user_id, &mut config).await?;
personalize_gateway_config(&state, &who, &mut config).await?;
- Ok(Json(config))
+ Ok((pin_headers, Json(config)).into_response())
}
async fn personalize_package_config(
@@ -6568,6 +6610,10 @@ fn uncertified_harness_range(config: &gh_service::GovernanceConfig) -> Option Result<(), String> {
+ if let Some(pin) = &config.required_client_version {
+ gh_service::GovernanceConfig::validate_client_version_pin(pin)
+ .map_err(|error| error.to_string())?;
+ }
if config.contract_version == 0
|| config.contract_version > gh_service::GovernanceConfig::CONTRACT_VERSION
{
@@ -8066,6 +8112,16 @@ async fn validate_package_audiences(
}
fn stamp_version_aware_client_floor(config: &mut gh_service::GovernanceConfig) {
+ if config.required_client_version.is_some()
+ && !config
+ .required_capabilities
+ .iter()
+ .any(|cap| cap == "tenant_client_version_pin")
+ {
+ config
+ .required_capabilities
+ .push("tenant_client_version_pin".into());
+ }
let required = config
.harnesses
.values()
@@ -12151,3 +12207,83 @@ mod gateway_ttl_config_tests {
);
}
}
+
+#[cfg(test)]
+mod client_version_pin_tests {
+ use super::*;
+ fn document() -> gh_service::GovernanceConfig {
+ serde_json::from_value(serde_json::json!({"revision":"r1"})).unwrap()
+ }
+ #[test]
+ fn default_client_version_uses_only_canonical_runtime_semver() {
+ for version in ["1.2.3", "1.2.3-rc.gabcdef0"] {
+ assert_eq!(default_client_version(Some(version)), version);
+ }
+
+ for version in [
+ None,
+ Some("development"),
+ Some("e2e"),
+ Some("v1.2.3"),
+ Some("1.2.03"),
+ ] {
+ assert_eq!(default_client_version(version), env!("CARGO_PKG_VERSION"));
+ }
+ }
+ #[test]
+ fn old_revisions_acquire_current_deployment_pin_without_persistence() {
+ let stored = document();
+ for runtime_version in ["1.2.3", "1.2.4-rc.gabcdef0"] {
+ let version = default_client_version(Some(runtime_version));
+ let mut served = stored.clone();
+ let headers = prepare_client_version_pin(&mut served, &version).unwrap();
+ assert_eq!(headers["x-blue-required-client-version"], version);
+ assert_eq!(
+ served.required_client_version.as_deref(),
+ Some(version.as_str())
+ );
+ assert!(served
+ .required_capabilities
+ .iter()
+ .any(|cap| cap == "tenant_client_version_pin"));
+ }
+ assert!(stored.required_client_version.is_none());
+ assert!(stored.required_capabilities.is_empty());
+ }
+ #[test]
+ fn override_and_admin_validation_use_exact_recommendation() {
+ let mut config = document();
+ config.required_client_version = Some("3.2.1".into());
+ stamp_version_aware_client_floor(&mut config);
+ assert!(validate_complete_governance(&config).is_ok());
+ let headers = prepare_client_version_pin(&mut config, "1.2.3").unwrap();
+ assert_eq!(headers["x-blue-required-client-version"], "3.2.1");
+ config.required_client_version = Some("^3.2.1".into());
+ assert!(validate_complete_governance(&config).is_err());
+ assert!(prepare_client_version_pin(&mut config, "1.2.3").is_err());
+ }
+ #[test]
+ fn successful_and_rejected_responses_expose_the_same_pin() {
+ let mut config = document();
+ let pin_headers = prepare_client_version_pin(&mut config, "1.2.3").unwrap();
+ let rejection = enforce_client_capabilities(&HeaderMap::new(), &config).unwrap_err();
+ let response = (pin_headers.clone(), rejection).into_response();
+ assert_eq!(response.status(), StatusCode::UPGRADE_REQUIRED);
+ assert_eq!(
+ response.headers()["x-blue-required-client-version"],
+ "1.2.3"
+ );
+ let mut supported = HeaderMap::new();
+ supported.insert(
+ "x-blue-capabilities",
+ "tenant_client_version_pin".parse().unwrap(),
+ );
+ assert!(enforce_client_capabilities(&supported, &config).is_ok());
+ let response = (pin_headers, Json(config)).into_response();
+ assert_eq!(response.status(), StatusCode::OK);
+ assert_eq!(
+ response.headers()["x-blue-required-client-version"],
+ "1.2.3"
+ );
+ }
+}
diff --git a/tests/e2e/specs/contract-surface.spec.ts b/tests/e2e/specs/contract-surface.spec.ts
index 9618b7a..117692a 100644
--- a/tests/e2e/specs/contract-surface.spec.ts
+++ b/tests/e2e/specs/contract-surface.spec.ts
@@ -35,6 +35,7 @@ type OpenApiDocument = {
operationId?: string;
security?: SecurityRequirement[];
requestBody?: { content?: Record };
+ responses?: Record }>;
}>>;
components?: { schemas?: Record };
};
@@ -138,6 +139,31 @@ test("every OpenAPI operation resolves and enforces its declared authentication
}
});
+test("OpenAPI declares the tenant client-version compatibility surface", async () => {
+ const { document } = await contract();
+ const schema = document.components?.schemas?.GovernanceConfig;
+ expect(schema).toBeDefined();
+ expect(typeof schema).not.toBe("boolean");
+ if (!schema || typeof schema === "boolean") return;
+
+ const capabilities = schema.properties?.required_capabilities;
+ expect(typeof capabilities).not.toBe("boolean");
+ if (!capabilities || typeof capabilities === "boolean") return;
+ const capabilityItems = capabilities.items;
+ expect(typeof capabilityItems).not.toBe("boolean");
+ if (!capabilityItems || typeof capabilityItems === "boolean") return;
+ expect(capabilityItems.enum).toContain("tenant_client_version_pin");
+ expect(schema.properties?.required_client_version).toBeDefined();
+
+ const responses = document.paths["/governance-config"]?.get?.responses;
+ for (const status of ["200", "426"]) {
+ expect(
+ responses?.[status]?.headers,
+ `GET /governance-config ${status} response headers`,
+ ).toHaveProperty("X-Blue-Required-Client-Version");
+ }
+});
+
test("coverage registry tracks all public CLI commands and harnesses", async () => {
const coverage = JSON.parse(await readFile(path.join(process.cwd(), "coverage.json"), "utf8"));
const help = await new Promise((resolve, reject) => {
diff --git a/tests/e2e/specs/gateway-m2m.spec.ts b/tests/e2e/specs/gateway-m2m.spec.ts
index 3191a18..63667f8 100644
--- a/tests/e2e/specs/gateway-m2m.spec.ts
+++ b/tests/e2e/specs/gateway-m2m.spec.ts
@@ -107,7 +107,7 @@ async function mintInferenceToken(
authorization: `Bearer ${session.token}`,
"x-blue-contract-version": "3",
"x-blue-capabilities":
- "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt",
+ "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,tenant_client_version_pin",
},
});
expect(config.status(), await config.text()).toBe(200);
@@ -265,7 +265,7 @@ test.describe.serial("Gateway M2M auth", () => {
authorization: `Bearer ${session.token}`,
"x-blue-contract-version": "3",
"x-blue-capabilities":
- "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt",
+ "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,tenant_client_version_pin",
},
},
);
diff --git a/tests/e2e/specs/journey.spec.ts b/tests/e2e/specs/journey.spec.ts
index 574141f..c68ed33 100644
--- a/tests/e2e/specs/journey.spec.ts
+++ b/tests/e2e/specs/journey.spec.ts
@@ -1622,7 +1622,7 @@ esac
headers: {
authorization: `Bearer ${memberOauth.token}`,
"x-blue-contract-version": "3",
- "x-blue-capabilities": "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,unverified_harness_versions",
+ "x-blue-capabilities": "adapter_intervals,compiled_harness_registry,transactional_reconcile,versioned_state,gateway_inference_jwt,unverified_harness_versions,tenant_client_version_pin",
},
});
expect(memberConfig.status(), await memberConfig.text()).toBe(200);