From 4b7b2ca81b1bfc52f8427ce50ad825aefb240273 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Sun, 14 Jun 2026 22:06:22 +0200 Subject: [PATCH 01/26] feat(ferrox-server): add tenant allowlist + runtime registry (M1.A1) Compile-time TENANTS table seeds an in-process Semaphore-per-tenant map. Acquire returns typed gRPC Status (PERMISSION_DENIED for unknown, RESOURCE_EXHAUSTED for over-cap). Quorum-sense seeded with 4 in-flight. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ferrox-server/src/main.rs | 1 + crates/ferrox-server/src/tenants.rs | 76 +++++++++++++++++++++++++++++ 2 files changed, 77 insertions(+) create mode 100644 crates/ferrox-server/src/tenants.rs diff --git a/crates/ferrox-server/src/main.rs b/crates/ferrox-server/src/main.rs index c6fde76..9b4771f 100644 --- a/crates/ferrox-server/src/main.rs +++ b/crates/ferrox-server/src/main.rs @@ -1,5 +1,6 @@ mod convert; mod service; +mod tenants; pub mod proto { pub mod ferrox { diff --git a/crates/ferrox-server/src/tenants.rs b/crates/ferrox-server/src/tenants.rs new file mode 100644 index 0000000..0fbcf1a --- /dev/null +++ b/crates/ferrox-server/src/tenants.rs @@ -0,0 +1,76 @@ +//! Tenant allowlist and per-tenant in-flight semaphore. +//! +//! Per design spec §6 (`marquee-apps/quorum-sense/docs/superpowers/specs/ +//! 2026-06-14-converge-grpc-suggestor-pattern-design.md`): +//! - Allowlist is compile-time `const TENANTS`. +//! - Adding a tenant requires a server image rebuild and redeploy. +//! - Per-tenant in-flight cap; over-limit returns `RESOURCE_EXHAUSTED`. +//! - Unknown tenant returns `PERMISSION_DENIED`. +//! - Missing `x-converge-app` header returns `INVALID_ARGUMENT`. + +use std::collections::HashMap; +use std::sync::Arc; + +use tokio::sync::{OwnedSemaphorePermit, Semaphore}; +use tonic::Status; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Tenant { + pub slug: &'static str, + pub max_in_flight: u32, +} + +pub const TENANTS: &[Tenant] = &[ + Tenant { slug: "quorum-sense", max_in_flight: 4 }, +]; + +/// Request-extension marker carrying the validated tenant slug into the +/// service layer. Attached by `request_interceptor` after the allowlist +/// check passes. +#[derive(Clone, Copy, Debug)] +pub struct TenantSlug(pub &'static str); + +/// Runtime view of the allowlist, holding one `Semaphore` per tenant. +#[derive(Debug)] +pub struct TenantRegistry { + permits: HashMap<&'static str, Arc>, +} + +impl TenantRegistry { + /// Build a registry from the compile-time `TENANTS` table. + #[must_use] + pub fn from_const() -> Self { + let permits = TENANTS + .iter() + .map(|t| (t.slug, Arc::new(Semaphore::new(t.max_in_flight as usize)))) + .collect(); + Self { permits } + } + + /// Return `Some(&'static Tenant)` if `slug` is on the allowlist. + #[must_use] + pub fn lookup(slug: &str) -> Option<&'static Tenant> { + TENANTS.iter().find(|t| t.slug == slug) + } + + /// Acquire a permit for `slug`, or return a typed gRPC `Status` error. + /// + /// - Unknown slug → `PERMISSION_DENIED` + /// - In-flight cap reached → `RESOURCE_EXHAUSTED` + /// - Semaphore closed (process shutdown) → `UNAVAILABLE` + pub async fn acquire(&self, slug: &str) -> Result { + let sem = self + .permits + .get(slug) + .ok_or_else(|| Status::permission_denied(format!("unknown tenant: {slug}")))? + .clone(); + sem.try_acquire_owned() + .map_err(|_| Status::resource_exhausted(format!("tenant {slug} at in-flight cap"))) + } +} + +impl Default for TenantRegistry { + fn default() -> Self { + Self::from_const() + } +} From fa6f13da0a0c03934ebf93cc04381354e9f563a0 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Sun, 14 Jun 2026 22:45:33 +0200 Subject: [PATCH 02/26] fix(ferrox-server): distinguish closed semaphore from over-cap in acquire (M1.A1-fixup) Per A1 code review: doc comment promises UNAVAILABLE for Closed semaphore case but code collapsed both TryAcquireError variants to RESOURCE_EXHAUSTED. Today nothing calls Semaphore::close(), but the doc is a contract that will matter once a graceful-shutdown path is added. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ferrox-server/src/tenants.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/ferrox-server/src/tenants.rs b/crates/ferrox-server/src/tenants.rs index 0fbcf1a..d0fc625 100644 --- a/crates/ferrox-server/src/tenants.rs +++ b/crates/ferrox-server/src/tenants.rs @@ -65,7 +65,14 @@ impl TenantRegistry { .ok_or_else(|| Status::permission_denied(format!("unknown tenant: {slug}")))? .clone(); sem.try_acquire_owned() - .map_err(|_| Status::resource_exhausted(format!("tenant {slug} at in-flight cap"))) + .map_err(|e| match e { + tokio::sync::TryAcquireError::NoPermits => { + Status::resource_exhausted(format!("tenant {slug} at in-flight cap")) + } + tokio::sync::TryAcquireError::Closed => { + Status::unavailable(format!("tenant {slug} semaphore closed")) + } + }) } } From 96e05d7f829f650c96d53de70a25f2e3c1039165 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Sun, 14 Jun 2026 22:51:56 +0200 Subject: [PATCH 03/26] test(ferrox-server): cover TenantRegistry allowlist + per-tenant cap (M1.A2) Add lib.rs so integration tests can reach tenants module. Tests cover lookup, unknown-tenant PERMISSION_DENIED, under-cap success, over-cap RESOURCE_EXHAUSTED. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ferrox-server/Cargo.toml | 4 ++ crates/ferrox-server/src/lib.rs | 4 ++ crates/ferrox-server/src/main.rs | 5 +- crates/ferrox-server/tests/tenant_registry.rs | 59 +++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 crates/ferrox-server/src/lib.rs create mode 100644 crates/ferrox-server/tests/tenant_registry.rs diff --git a/crates/ferrox-server/Cargo.toml b/crates/ferrox-server/Cargo.toml index ae4a0b3..7ab63b2 100644 --- a/crates/ferrox-server/Cargo.toml +++ b/crates/ferrox-server/Cargo.toml @@ -8,6 +8,10 @@ repository.workspace = true publish = false description = "gRPC server exposing ferrox solvers (CP-SAT, GLOP, HiGHS)" +[lib] +name = "converge_ferrox_server" +path = "src/lib.rs" + [[bin]] name = "ferrox-server" path = "src/main.rs" diff --git a/crates/ferrox-server/src/lib.rs b/crates/ferrox-server/src/lib.rs new file mode 100644 index 0000000..80f46dd --- /dev/null +++ b/crates/ferrox-server/src/lib.rs @@ -0,0 +1,4 @@ +//! Public surface of `converge-ferrox-server` for integration tests + future +//! library consumers. The binary entrypoint stays in `main.rs`. + +pub mod tenants; diff --git a/crates/ferrox-server/src/main.rs b/crates/ferrox-server/src/main.rs index 9b4771f..1f31b12 100644 --- a/crates/ferrox-server/src/main.rs +++ b/crates/ferrox-server/src/main.rs @@ -1,6 +1,9 @@ mod convert; mod service; -mod tenants; +// Re-import from the library target so integration tests share the same module path. +// Unused for now; wired in by M1.A3 when the tenant interceptor lands. +#[allow(unused_imports)] +use converge_ferrox_server::tenants; pub mod proto { pub mod ferrox { diff --git a/crates/ferrox-server/tests/tenant_registry.rs b/crates/ferrox-server/tests/tenant_registry.rs new file mode 100644 index 0000000..ea8b2b4 --- /dev/null +++ b/crates/ferrox-server/tests/tenant_registry.rs @@ -0,0 +1,59 @@ +//! Integration tests for the tenant allowlist + per-tenant semaphore. + +use converge_ferrox_server::tenants::{Tenant, TenantRegistry, TENANTS}; +use tonic::Code; + +#[test] +fn quorum_sense_is_on_the_allowlist() { + let t = TenantRegistry::lookup("quorum-sense").expect("seeded tenant"); + assert_eq!(t.slug, "quorum-sense"); + assert!(t.max_in_flight >= 1, "in-flight cap must be positive"); +} + +#[test] +fn unknown_tenant_returns_none_from_lookup() { + assert!(TenantRegistry::lookup("nope-not-real").is_none()); +} + +#[tokio::test] +async fn acquire_unknown_tenant_returns_permission_denied() { + let reg = TenantRegistry::default(); + let err = reg.acquire("nope-not-real").await.expect_err("should error"); + assert_eq!(err.code(), Code::PermissionDenied); + assert!(err.message().contains("unknown tenant")); +} + +#[tokio::test] +async fn acquire_known_tenant_below_cap_succeeds() { + let reg = TenantRegistry::default(); + let _permit = reg.acquire("quorum-sense").await.expect("permit"); + // _permit drops at end of scope, returning the slot. +} + +#[tokio::test] +async fn acquire_over_cap_returns_resource_exhausted() { + let reg = TenantRegistry::default(); + let cap = Tenant::lookup_const_cap("quorum-sense"); + let mut held = Vec::with_capacity(cap); + for _ in 0..cap { + held.push(reg.acquire("quorum-sense").await.expect("under cap")); + } + let err = reg.acquire("quorum-sense").await.expect_err("over cap"); + assert_eq!(err.code(), Code::ResourceExhausted); + assert!(err.message().contains("at in-flight cap")); +} + +// Helper trait so the test can read the const cap without exporting more. +trait LookupConstCap { + fn lookup_const_cap(slug: &str) -> usize; +} + +impl LookupConstCap for Tenant { + fn lookup_const_cap(slug: &str) -> usize { + TENANTS + .iter() + .find(|t| t.slug == slug) + .map(|t| t.max_in_flight as usize) + .expect("seeded tenant") + } +} From 4ae91b6b5a856b8904bd104b91e17094a2ebb8e3 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 09:21:58 +0200 Subject: [PATCH 04/26] feat(ferrox-server): combined bearer + tenant interceptor (M1.A3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace auth_interceptor (bearer only) with request_interceptor (bearer optional + x-converge-app required). Validated TenantSlug is attached to request extensions for the service layer to pick up. Missing header → INVALID_ARGUMENT; unknown slug → PERMISSION_DENIED; bad bearer → UNAUTHENTICATED. Matches spec §6 + §7.2. Also folds two A2 review followups: - Promote integration-test cap lookup to Tenant::cap_for; drop the LookupConstCap helper trait. - Drop A2's #[allow(unused_imports)] on the main.rs tenants use stmt; the interceptor consumes it via crate::tenants now. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ferrox-server/src/interceptor.rs | 39 +++++++++++++++++++ crates/ferrox-server/src/lib.rs | 1 + crates/ferrox-server/src/main.rs | 28 ++----------- crates/ferrox-server/src/tenants.rs | 8 ++++ crates/ferrox-server/tests/tenant_registry.rs | 19 +-------- 5 files changed, 53 insertions(+), 42 deletions(-) create mode 100644 crates/ferrox-server/src/interceptor.rs diff --git a/crates/ferrox-server/src/interceptor.rs b/crates/ferrox-server/src/interceptor.rs new file mode 100644 index 0000000..19ce1f4 --- /dev/null +++ b/crates/ferrox-server/src/interceptor.rs @@ -0,0 +1,39 @@ +//! Tonic interceptor: validate `Authorization` (optional bearer) and +//! `x-converge-app` (tenant), attaching the validated `TenantSlug` to the +//! request extensions so the service layer can acquire a per-tenant permit +//! without re-parsing metadata. +//! +//! Per spec §6 and §7.2 — bearer is optional (gated by `FERROX_AUTH_TOKEN` +//! env presence); tenant header is required. + +use tonic::{Request, Status}; + +use crate::tenants::{TenantRegistry, TenantSlug}; + +#[allow(clippy::result_large_err)] +pub fn request_interceptor(mut req: Request<()>) -> Result, Status> { + // ─ Bearer (optional — disabled when env unset) ───────────────────────── + if let Ok(expected) = std::env::var("FERROX_AUTH_TOKEN") { + let provided = req + .metadata() + .get("authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or(""); + if provided != format!("Bearer {expected}") { + return Err(Status::unauthenticated("invalid or missing token")); + } + } + + // ─ Tenant (required) ─────────────────────────────────────────────────── + let slug = req + .metadata() + .get("x-converge-app") + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| Status::invalid_argument("missing x-converge-app header"))?; + + let tenant = TenantRegistry::lookup(slug) + .ok_or_else(|| Status::permission_denied(format!("unknown tenant: {slug}")))?; + + req.extensions_mut().insert(TenantSlug(tenant.slug)); + Ok(req) +} diff --git a/crates/ferrox-server/src/lib.rs b/crates/ferrox-server/src/lib.rs index 80f46dd..86283d5 100644 --- a/crates/ferrox-server/src/lib.rs +++ b/crates/ferrox-server/src/lib.rs @@ -1,4 +1,5 @@ //! Public surface of `converge-ferrox-server` for integration tests + future //! library consumers. The binary entrypoint stays in `main.rs`. +pub mod interceptor; pub mod tenants; diff --git a/crates/ferrox-server/src/main.rs b/crates/ferrox-server/src/main.rs index 1f31b12..4cee9a9 100644 --- a/crates/ferrox-server/src/main.rs +++ b/crates/ferrox-server/src/main.rs @@ -1,9 +1,5 @@ mod convert; mod service; -// Re-import from the library target so integration tests share the same module path. -// Unused for now; wired in by M1.A3 when the tenant interceptor lands. -#[allow(unused_imports)] -use converge_ferrox_server::tenants; pub mod proto { pub mod ferrox { @@ -22,29 +18,11 @@ pub mod proto { use std::net::SocketAddr; use tonic::transport::{Identity, Server, ServerTlsConfig}; -use tonic::{Request, Status}; +use converge_ferrox_server::interceptor::request_interceptor; use proto::ferrox::v1::ferrox_solver_server::FerroxSolverServer; use service::FerroxSolverService; -#[allow(clippy::result_large_err)] -fn auth_interceptor(req: Request<()>) -> Result, Status> { - let expected = std::env::var("FERROX_AUTH_TOKEN").ok(); - let Some(token) = expected else { - return Ok(req); // auth disabled when env var is absent - }; - let provided = req - .metadata() - .get("authorization") - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - if provided == format!("Bearer {token}") { - Ok(req) - } else { - Err(Status::unauthenticated("invalid or missing token")) - } -} - #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -81,13 +59,13 @@ async fn main() -> anyhow::Result<()> { Server::builder() .tls_config(tls)? - .add_service(FerroxSolverServer::with_interceptor(svc, auth_interceptor)) + .add_service(FerroxSolverServer::with_interceptor(svc, request_interceptor)) .serve(addr) .await?; } else { tracing::warn!("TLS cert/key not found — starting without TLS (dev/test only)"); Server::builder() - .add_service(FerroxSolverServer::with_interceptor(svc, auth_interceptor)) + .add_service(FerroxSolverServer::with_interceptor(svc, request_interceptor)) .serve(addr) .await?; } diff --git a/crates/ferrox-server/src/tenants.rs b/crates/ferrox-server/src/tenants.rs index d0fc625..14ec4f8 100644 --- a/crates/ferrox-server/src/tenants.rs +++ b/crates/ferrox-server/src/tenants.rs @@ -24,6 +24,14 @@ pub const TENANTS: &[Tenant] = &[ Tenant { slug: "quorum-sense", max_in_flight: 4 }, ]; +impl Tenant { + /// Return the in-flight cap for `slug`, or `None` if not on the allowlist. + #[must_use] + pub fn cap_for(slug: &str) -> Option { + TENANTS.iter().find(|t| t.slug == slug).map(|t| t.max_in_flight) + } +} + /// Request-extension marker carrying the validated tenant slug into the /// service layer. Attached by `request_interceptor` after the allowlist /// check passes. diff --git a/crates/ferrox-server/tests/tenant_registry.rs b/crates/ferrox-server/tests/tenant_registry.rs index ea8b2b4..9dd649a 100644 --- a/crates/ferrox-server/tests/tenant_registry.rs +++ b/crates/ferrox-server/tests/tenant_registry.rs @@ -1,6 +1,6 @@ //! Integration tests for the tenant allowlist + per-tenant semaphore. -use converge_ferrox_server::tenants::{Tenant, TenantRegistry, TENANTS}; +use converge_ferrox_server::tenants::{Tenant, TenantRegistry}; use tonic::Code; #[test] @@ -33,7 +33,7 @@ async fn acquire_known_tenant_below_cap_succeeds() { #[tokio::test] async fn acquire_over_cap_returns_resource_exhausted() { let reg = TenantRegistry::default(); - let cap = Tenant::lookup_const_cap("quorum-sense"); + let cap = Tenant::cap_for("quorum-sense").expect("seeded") as usize; let mut held = Vec::with_capacity(cap); for _ in 0..cap { held.push(reg.acquire("quorum-sense").await.expect("under cap")); @@ -42,18 +42,3 @@ async fn acquire_over_cap_returns_resource_exhausted() { assert_eq!(err.code(), Code::ResourceExhausted); assert!(err.message().contains("at in-flight cap")); } - -// Helper trait so the test can read the const cap without exporting more. -trait LookupConstCap { - fn lookup_const_cap(slug: &str) -> usize; -} - -impl LookupConstCap for Tenant { - fn lookup_const_cap(slug: &str) -> usize { - TENANTS - .iter() - .find(|t| t.slug == slug) - .map(|t| t.max_in_flight as usize) - .expect("seeded tenant") - } -} From 08e1d5f6232d040375bbf7371509dd7b1b9a7ea2 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 09:35:00 +0200 Subject: [PATCH 05/26] feat(ferrox-server): per-tenant permit before global solve limit (M1.A4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit FerroxSolverService now holds Arc. run_blocking acquires the tenant permit BEFORE the global solver semaphore, so a tenant over their cap fails fast without consuming a global slot. RPC methods read the slug from request extensions (attached by request_interceptor). Also resolves the unused_async warning on TenantRegistry::acquire that A1's fixup introduced — kept the async fn signature with #[allow(clippy::unused_async)] plus a rationale comment, so callers in service.rs use a consistent .await? chain alongside the global semaphore acquire and we preserve the API shape if we later switch acquire to a blocking acquire_owned().await. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ferrox-server/src/service.rs | 37 ++++++++++++++++++++++++----- crates/ferrox-server/src/tenants.rs | 4 ++++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/crates/ferrox-server/src/service.rs b/crates/ferrox-server/src/service.rs index 2c8b5d5..a8f510c 100644 --- a/crates/ferrox-server/src/service.rs +++ b/crates/ferrox-server/src/service.rs @@ -16,24 +16,37 @@ use crate::proto::ferrox::v1::{ SolveCpRequest, SolveCpResponse, SolveLpRequest, SolveLpResponse, SolveMipRequest, SolveMipResponse, }; +use converge_ferrox_server::tenants::{TenantRegistry, TenantSlug}; #[derive(Clone)] pub struct FerroxSolverService { solve_limit: Arc, + tenants: Arc, } impl FerroxSolverService { - pub fn new(max_blocking_solves: usize) -> Self { + pub fn new(max_blocking_solves: usize, tenants: Arc) -> Self { Self { solve_limit: Arc::new(Semaphore::new(max_blocking_solves.max(1))), + tenants, } } - async fn run_blocking(&self, operation: &'static str, solve: F) -> Result + async fn run_blocking( + &self, + operation: &'static str, + tenant_slug: &str, + solve: F, + ) -> Result where T: Send + 'static, F: FnOnce() -> T + Send + 'static, { + // Per-tenant permit first — if the tenant is over their cap, fail + // fast without consuming the global solver slot. + let _tenant_permit = self.tenants.acquire(tenant_slug).await?; + + // Global solver permit — bounds total concurrent native solves. let permit = self .solve_limit .clone() @@ -52,7 +65,7 @@ impl FerroxSolverService { impl Default for FerroxSolverService { fn default() -> Self { - Self::new(configured_blocking_solves()) + Self::new(configured_blocking_solves(), Arc::new(TenantRegistry::default())) } } @@ -73,15 +86,25 @@ fn configured_blocking_solves() -> usize { } } +fn tenant_slug_from(req: &Request) -> Result<&'static str, Status> { + req.extensions() + .get::() + .map(|t| t.0) + .ok_or_else(|| { + Status::internal("request reached service without TenantSlug extension") + }) +} + #[tonic::async_trait] impl FerroxSolver for FerroxSolverService { async fn solve_cp( &self, request: Request, ) -> Result, Status> { + let tenant = tenant_slug_from(&request)?; let req = cp_req_from_proto(request.into_inner())?; let plan = self - .run_blocking("solve_cp", move || solve_cp(&req)) + .run_blocking("solve_cp", tenant, move || solve_cp(&req)) .await?; Ok(Response::new(cp_resp_to_proto(plan))) } @@ -90,9 +113,10 @@ impl FerroxSolver for FerroxSolverService { &self, request: Request, ) -> Result, Status> { + let tenant = tenant_slug_from(&request)?; let req = lp_req_from_proto(request.into_inner())?; let plan = self - .run_blocking("solve_lp", move || solve_lp(&req)) + .run_blocking("solve_lp", tenant, move || solve_lp(&req)) .await?; Ok(Response::new(lp_resp_to_proto(plan))) } @@ -101,9 +125,10 @@ impl FerroxSolver for FerroxSolverService { &self, request: Request, ) -> Result, Status> { + let tenant = tenant_slug_from(&request)?; let req = mip_req_from_proto(request.into_inner())?; let plan = self - .run_blocking("solve_mip", move || solve_mip(&req)) + .run_blocking("solve_mip", tenant, move || solve_mip(&req)) .await?; Ok(Response::new(mip_resp_to_proto(plan))) } diff --git a/crates/ferrox-server/src/tenants.rs b/crates/ferrox-server/src/tenants.rs index 14ec4f8..31e9a82 100644 --- a/crates/ferrox-server/src/tenants.rs +++ b/crates/ferrox-server/src/tenants.rs @@ -66,6 +66,10 @@ impl TenantRegistry { /// - Unknown slug → `PERMISSION_DENIED` /// - In-flight cap reached → `RESOURCE_EXHAUSTED` /// - Semaphore closed (process shutdown) → `UNAVAILABLE` + // Kept `async fn` to preserve the API shape if we later switch to + // `acquire_owned().await` (blocking) — callers shouldn't have to flip + // between `.await` and no-await. + #[allow(clippy::unused_async)] pub async fn acquire(&self, slug: &str) -> Result { let sem = self .permits From b14b71129de4668d2a1ca71551b3a93e8e781f28 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 10:38:11 +0200 Subject: [PATCH 06/26] test(ferrox-server): interceptor unit tests + TenantSlug extension contract (M1.A5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds: - extensions_carry_tenant_slug_into_service: pins the request-extension contract between interceptor and service. A future refactor of either side can't silently drop the slug. - 6 request_interceptor unit tests covering: known tenant attaches slug, missing header → INVALID_ARGUMENT, unknown slug → PERMISSION_DENIED, bearer-disabled bypass (env unset), bad bearer → UNAUTHENTICATED, good bearer accepted. Env-mutation choice (deviation from brief): The brief proposed serial_test + unsafe { std::env::set_var(...) }, but the workspace lint `unsafe_code = "forbid"` cannot be relaxed by `#![allow(unsafe_code)]` (forbid blocks allow per E0453). Switched to `temp-env = "0.3"` which exposes a safe scoped API (`with_var` / `with_var_unset`) and keeps the unsafe internalized inside that crate. Kept `serial_test = "3"` with `#[serial(env)]` on the env-touching tests as defense in depth, since `request_interceptor` reads the env directly and could observe a peer test's mutation otherwise. All 12 tests pass (5 existing + 1 extension contract + 6 interceptor). Closes A3 reviewer Minor #6. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 94 ++++++++++++++ crates/ferrox-server/Cargo.toml | 4 + crates/ferrox-server/tests/tenant_registry.rs | 117 ++++++++++++++++++ 3 files changed, 215 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 9c851f4..ec30c02 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -187,6 +187,8 @@ dependencies = [ "converge-ferrox-ortools-sys", "converge-ferrox-solver", "prost", + "serial_test", + "temp-env", "tokio", "tonic", "tonic-prost", @@ -342,6 +344,17 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + [[package]] name = "futures-sink" version = "0.3.32" @@ -614,6 +627,15 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + [[package]] name = "log" version = "0.4.29" @@ -688,6 +710,29 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -916,6 +961,15 @@ dependencies = [ "rand_core", ] +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + [[package]] name = "regex" version = "1.12.3" @@ -1019,6 +1073,12 @@ dependencies = [ "wait-timeout", ] +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + [[package]] name = "semver" version = "1.0.28" @@ -1069,6 +1129,31 @@ dependencies = [ "zmij", ] +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "sha2" version = "0.11.0" @@ -1161,6 +1246,15 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +[[package]] +name = "temp-env" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96374855068f47402c3121c6eed88d29cb1de8f3ab27090e273e420bdabcf050" +dependencies = [ + "parking_lot", +] + [[package]] name = "tempfile" version = "3.27.0" diff --git a/crates/ferrox-server/Cargo.toml b/crates/ferrox-server/Cargo.toml index 7ab63b2..4a6a78e 100644 --- a/crates/ferrox-server/Cargo.toml +++ b/crates/ferrox-server/Cargo.toml @@ -37,5 +37,9 @@ anyhow = "1" [build-dependencies] tonic-prost-build = "0.14" +[dev-dependencies] +serial_test = "3" +temp-env = "0.3" + [lints] workspace = true diff --git a/crates/ferrox-server/tests/tenant_registry.rs b/crates/ferrox-server/tests/tenant_registry.rs index 9dd649a..5d51d9a 100644 --- a/crates/ferrox-server/tests/tenant_registry.rs +++ b/crates/ferrox-server/tests/tenant_registry.rs @@ -1,4 +1,12 @@ //! Integration tests for the tenant allowlist + per-tenant semaphore. +//! +//! The interceptor unit tests below mutate the `FERROX_AUTH_TOKEN` env var. +//! Rust 1.85+ (edition 2024) makes `std::env::set_var` / `remove_var` +//! `unsafe`, and the workspace lint forbids `unsafe_code` even in tests. +//! We use the `temp-env` crate, which serializes env mutations behind a +//! global mutex and keeps the unsafe internalized to that crate. +//! `serial_test` is layered on top to keep the scoped guards from racing +//! with each other across tokio's worker threads. use converge_ferrox_server::tenants::{Tenant, TenantRegistry}; use tonic::Code; @@ -42,3 +50,112 @@ async fn acquire_over_cap_returns_resource_exhausted() { assert_eq!(err.code(), Code::ResourceExhausted); assert!(err.message().contains("at in-flight cap")); } + +// ─── End-to-end interceptor + service path ────────────────────────────── + +use converge_ferrox_server::tenants::TenantSlug; +use tonic::Request; + +/// Simulate what the interceptor does: attach `TenantSlug` to extensions. +fn with_tenant(mut req: Request, slug: &'static str) -> Request { + req.extensions_mut().insert(TenantSlug(slug)); + req +} + +#[tokio::test] +async fn extensions_carry_tenant_slug_into_service() { + let req = with_tenant(Request::new(()), "quorum-sense"); + let slug = req + .extensions() + .get::() + .map(|t| t.0) + .expect("tenant attached"); + assert_eq!(slug, "quorum-sense"); +} + +// ─── request_interceptor unit tests ───────────────────────────────────── + +use converge_ferrox_server::interceptor::request_interceptor; +use serial_test::serial; +use tonic::metadata::MetadataValue; + +const AUTH_ENV: &str = "FERROX_AUTH_TOKEN"; + +/// Helper: build a Request<()> with a tenant header, no bearer. +fn req_with_tenant(slug: &str) -> Request<()> { + let mut req = Request::new(()); + let value: MetadataValue<_> = slug.parse().expect("ascii slug"); + req.metadata_mut().insert("x-converge-app", value); + req +} + +#[test] +#[serial(env)] +fn interceptor_attaches_tenant_slug_on_known_tenant() { + temp_env::with_var_unset(AUTH_ENV, || { + let req = req_with_tenant("quorum-sense"); + let req = request_interceptor(req).expect("known tenant accepted"); + let attached = req + .extensions() + .get::() + .map(|t| t.0) + .expect("interceptor attaches slug"); + assert_eq!(attached, "quorum-sense"); + }); +} + +#[test] +#[serial(env)] +fn interceptor_rejects_missing_tenant_header() { + temp_env::with_var_unset(AUTH_ENV, || { + let req = Request::new(()); + let err = request_interceptor(req).expect_err("missing header → INVALID_ARGUMENT"); + assert_eq!(err.code(), Code::InvalidArgument); + assert!(err.message().contains("missing x-converge-app")); + }); +} + +#[test] +#[serial(env)] +fn interceptor_rejects_unknown_tenant() { + temp_env::with_var_unset(AUTH_ENV, || { + let req = req_with_tenant("nope-not-real"); + let err = request_interceptor(req).expect_err("unknown tenant → PERMISSION_DENIED"); + assert_eq!(err.code(), Code::PermissionDenied); + assert!(err.message().contains("unknown tenant")); + }); +} + +#[test] +#[serial(env)] +fn interceptor_bypasses_bearer_when_env_unset() { + temp_env::with_var_unset(AUTH_ENV, || { + let req = req_with_tenant("quorum-sense"); + let result = request_interceptor(req); + assert!(result.is_ok(), "bearer disabled when env unset"); + }); +} + +#[test] +#[serial(env)] +fn interceptor_rejects_bad_bearer_when_env_set() { + temp_env::with_var(AUTH_ENV, Some("expected-token"), || { + let mut req = req_with_tenant("quorum-sense"); + let auth: MetadataValue<_> = "Bearer wrong-token".parse().expect("ascii"); + req.metadata_mut().insert("authorization", auth); + let err = request_interceptor(req).expect_err("bad bearer → UNAUTHENTICATED"); + assert_eq!(err.code(), Code::Unauthenticated); + }); +} + +#[test] +#[serial(env)] +fn interceptor_accepts_good_bearer_when_env_set() { + temp_env::with_var(AUTH_ENV, Some("expected-token"), || { + let mut req = req_with_tenant("quorum-sense"); + let auth: MetadataValue<_> = "Bearer expected-token".parse().expect("ascii"); + req.metadata_mut().insert("authorization", auth); + let result = request_interceptor(req); + assert!(result.is_ok(), "good bearer accepted"); + }); +} From a1c95bfc4ee0a5eb09bbbf88b14589c003b87627 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 10:46:18 +0200 Subject: [PATCH 07/26] deps(ferrox-server): add tonic-health 0.14 (M1.B1) Standard grpc.health.v1.Health service for Cloud Run probes and uniform infra-side health checks. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 15 +++++++++++++++ crates/ferrox-server/Cargo.toml | 1 + 2 files changed, 16 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index ec30c02..63ea0f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -191,6 +191,7 @@ dependencies = [ "temp-env", "tokio", "tonic", + "tonic-health", "tonic-prost", "tonic-prost-build", "tracing", @@ -1342,6 +1343,7 @@ dependencies = [ "futures-core", "pin-project-lite", "tokio", + "tokio-util", ] [[package]] @@ -1399,6 +1401,19 @@ dependencies = [ "syn", ] +[[package]] +name = "tonic-health" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcfab99db777fba2802f0dfa861d1628d1ae916fb199d29819941f139ae85082" +dependencies = [ + "prost", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + [[package]] name = "tonic-prost" version = "0.14.6" diff --git a/crates/ferrox-server/Cargo.toml b/crates/ferrox-server/Cargo.toml index 4a6a78e..c6c73f5 100644 --- a/crates/ferrox-server/Cargo.toml +++ b/crates/ferrox-server/Cargo.toml @@ -28,6 +28,7 @@ ferrox-highs-sys = { workspace = true, optional = true } tonic = { version = "0.14", features = ["tls-ring"] } tonic-prost = "0.14" +tonic-health = "0.14" prost = "0.14" tokio = { workspace = true } tracing = { workspace = true } From 8df417884c61367a8deb6b079192c755093638dc Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 10:46:58 +0200 Subject: [PATCH 08/26] feat(ferrox-server): expose grpc.health.v1.Health (M1.B2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Health service added outside the tenant interceptor — health probes do not need x-converge-app. Cloud Run startup/liveness probes and grpcurl can hit grpc.health.v1.Health/Check uniformly. Local smoke deferred to Cloud Run smoke in Phase E (avoids needing OR-Tools built locally). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ferrox-server/src/main.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/crates/ferrox-server/src/main.rs b/crates/ferrox-server/src/main.rs index 4cee9a9..43bccc1 100644 --- a/crates/ferrox-server/src/main.rs +++ b/crates/ferrox-server/src/main.rs @@ -38,6 +38,13 @@ async fn main() -> anyhow::Result<()> { let svc = FerroxSolverService::default(); + // Health checking — standard grpc.health.v1.Health, exposed without + // tenant gating so Cloud Run probes and grpcurl can hit it freely. + let (health_reporter, health_service) = tonic_health::server::health_reporter(); + health_reporter + .set_serving::>() + .await; + let cert_path = std::env::var("FERROX_TLS_CERT").unwrap_or_else(|_| "/tls/server.crt".into()); let key_path = std::env::var("FERROX_TLS_KEY").unwrap_or_else(|_| "/tls/server.key".into()); @@ -59,12 +66,14 @@ async fn main() -> anyhow::Result<()> { Server::builder() .tls_config(tls)? + .add_service(health_service) .add_service(FerroxSolverServer::with_interceptor(svc, request_interceptor)) .serve(addr) .await?; } else { tracing::warn!("TLS cert/key not found — starting without TLS (dev/test only)"); Server::builder() + .add_service(health_service) .add_service(FerroxSolverServer::with_interceptor(svc, request_interceptor)) .serve(addr) .await?; From b995c5a8ad58aa3eed1d2371676cff4fb3fd70e2 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 10:55:02 +0200 Subject: [PATCH 09/26] feat(ferrox-server): structured JSON logging for Cloud Logging (M1.C1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Switch tracing_subscriber to .json() with current span emitted, span list suppressed. Per spec §9.2 — Cloud Logging filters need jsonPayload.= shape; flat-text fmt() won't index correctly. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 13 +++++++++++++ crates/ferrox-server/Cargo.toml | 2 +- crates/ferrox-server/src/main.rs | 3 +++ 3 files changed, 17 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 63ea0f2..fc7ab70 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1515,6 +1515,16 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + [[package]] name = "tracing-subscriber" version = "0.3.23" @@ -1525,12 +1535,15 @@ dependencies = [ "nu-ansi-term", "once_cell", "regex-automata", + "serde", + "serde_json", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log", + "tracing-serde", ] [[package]] diff --git a/crates/ferrox-server/Cargo.toml b/crates/ferrox-server/Cargo.toml index c6c73f5..afaa8fa 100644 --- a/crates/ferrox-server/Cargo.toml +++ b/crates/ferrox-server/Cargo.toml @@ -32,7 +32,7 @@ tonic-health = "0.14" prost = "0.14" tokio = { workspace = true } tracing = { workspace = true } -tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } anyhow = "1" [build-dependencies] diff --git a/crates/ferrox-server/src/main.rs b/crates/ferrox-server/src/main.rs index 43bccc1..291a6f2 100644 --- a/crates/ferrox-server/src/main.rs +++ b/crates/ferrox-server/src/main.rs @@ -26,6 +26,9 @@ use service::FerroxSolverService; #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() + .json() + .with_current_span(true) + .with_span_list(false) .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| "ferrox_server=info".parse().unwrap()), From c0bd8000af2358215d055ee374dff73d0373e8b6 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 10:56:11 +0200 Subject: [PATCH 10/26] feat(ferrox-server): per-RPC tracing spans + request_id (M1.C2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Interceptor mints or accepts x-request-id and attaches RequestId to extensions. Each solve_* method opens an info_span with tenant_app, request_id, rpc_method, then logs solve_duration_us + status on completion. Matches spec §9.2. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 80 +++++++++++++++++++++++++ crates/ferrox-server/Cargo.toml | 1 + crates/ferrox-server/src/interceptor.rs | 18 +++++- crates/ferrox-server/src/service.rs | 63 ++++++++++++++++--- 4 files changed, 151 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index fc7ab70..fb5e76c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -119,6 +119,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.11.1" @@ -196,6 +202,7 @@ dependencies = [ "tonic-prost-build", "tracing", "tracing-subscriber", + "uuid", ] [[package]] @@ -604,6 +611,17 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -1062,6 +1080,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + [[package]] name = "rusty-fork" version = "0.3.1" @@ -1608,6 +1632,17 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "uuid" +version = "1.23.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "144d6b123cef80b301b8f72a9e2ca4370ddec21950d0a103dd22c437006d2db7" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" @@ -1656,6 +1691,51 @@ dependencies = [ "wit-bindgen 0.51.0", ] +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + [[package]] name = "wasm-encoder" version = "0.244.0" diff --git a/crates/ferrox-server/Cargo.toml b/crates/ferrox-server/Cargo.toml index afaa8fa..c4f30b6 100644 --- a/crates/ferrox-server/Cargo.toml +++ b/crates/ferrox-server/Cargo.toml @@ -34,6 +34,7 @@ tokio = { workspace = true } tracing = { workspace = true } tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } anyhow = "1" +uuid = { version = "1", features = ["v4"] } [build-dependencies] tonic-prost-build = "0.14" diff --git a/crates/ferrox-server/src/interceptor.rs b/crates/ferrox-server/src/interceptor.rs index 19ce1f4..754c5f9 100644 --- a/crates/ferrox-server/src/interceptor.rs +++ b/crates/ferrox-server/src/interceptor.rs @@ -1,15 +1,18 @@ //! Tonic interceptor: validate `Authorization` (optional bearer) and -//! `x-converge-app` (tenant), attaching the validated `TenantSlug` to the -//! request extensions so the service layer can acquire a per-tenant permit -//! without re-parsing metadata. +//! `x-converge-app` (tenant); mint/accept `x-request-id`; attach both to +//! request extensions so the service layer can use them in spans. //! //! Per spec §6 and §7.2 — bearer is optional (gated by `FERROX_AUTH_TOKEN` //! env presence); tenant header is required. use tonic::{Request, Status}; +use uuid::Uuid; use crate::tenants::{TenantRegistry, TenantSlug}; +#[derive(Clone, Debug)] +pub struct RequestId(pub String); + #[allow(clippy::result_large_err)] pub fn request_interceptor(mut req: Request<()>) -> Result, Status> { // ─ Bearer (optional — disabled when env unset) ───────────────────────── @@ -34,6 +37,15 @@ pub fn request_interceptor(mut req: Request<()>) -> Result, Status> let tenant = TenantRegistry::lookup(slug) .ok_or_else(|| Status::permission_denied(format!("unknown tenant: {slug}")))?; + // ─ Request ID (mint if absent) ───────────────────────────────────────── + let request_id = req + .metadata() + .get("x-request-id") + .and_then(|v| v.to_str().ok()) + .map(String::from) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + req.extensions_mut().insert(TenantSlug(tenant.slug)); + req.extensions_mut().insert(RequestId(request_id)); Ok(req) } diff --git a/crates/ferrox-server/src/service.rs b/crates/ferrox-server/src/service.rs index a8f510c..57ae3f9 100644 --- a/crates/ferrox-server/src/service.rs +++ b/crates/ferrox-server/src/service.rs @@ -16,6 +16,7 @@ use crate::proto::ferrox::v1::{ SolveCpRequest, SolveCpResponse, SolveLpRequest, SolveLpResponse, SolveMipRequest, SolveMipResponse, }; +use converge_ferrox_server::interceptor::RequestId; use converge_ferrox_server::tenants::{TenantRegistry, TenantSlug}; #[derive(Clone)] @@ -86,13 +87,23 @@ fn configured_blocking_solves() -> usize { } } -fn tenant_slug_from(req: &Request) -> Result<&'static str, Status> { - req.extensions() +struct RequestContext<'a> { + tenant: &'static str, + request_id: &'a str, +} + +fn request_context(req: &Request) -> Result, Status> { + let tenant = req + .extensions() .get::() .map(|t| t.0) - .ok_or_else(|| { - Status::internal("request reached service without TenantSlug extension") - }) + .ok_or_else(|| Status::internal("missing TenantSlug extension"))?; + let request_id = req + .extensions() + .get::() + .map(|r| r.0.as_str()) + .ok_or_else(|| Status::internal("missing RequestId extension"))?; + Ok(RequestContext { tenant, request_id }) } #[tonic::async_trait] @@ -101,11 +112,23 @@ impl FerroxSolver for FerroxSolverService { &self, request: Request, ) -> Result, Status> { - let tenant = tenant_slug_from(&request)?; + let ctx = request_context(&request)?; + let span = tracing::info_span!( + "solve_cp", + tenant_app = %ctx.tenant, + request_id = %ctx.request_id, + rpc_method = "ferrox.v1.FerroxSolver/SolveCp", + ); + let _enter = span.enter(); + let started = std::time::Instant::now(); + let tenant = ctx.tenant; + drop(_enter); // drop guard before await let req = cp_req_from_proto(request.into_inner())?; let plan = self .run_blocking("solve_cp", tenant, move || solve_cp(&req)) .await?; + let elapsed_us = started.elapsed().as_micros(); + tracing::info!(solve_duration_us = elapsed_us, status = "ok", "solve_cp completed"); Ok(Response::new(cp_resp_to_proto(plan))) } @@ -113,11 +136,23 @@ impl FerroxSolver for FerroxSolverService { &self, request: Request, ) -> Result, Status> { - let tenant = tenant_slug_from(&request)?; + let ctx = request_context(&request)?; + let span = tracing::info_span!( + "solve_lp", + tenant_app = %ctx.tenant, + request_id = %ctx.request_id, + rpc_method = "ferrox.v1.FerroxSolver/SolveLp", + ); + let _enter = span.enter(); + let started = std::time::Instant::now(); + let tenant = ctx.tenant; + drop(_enter); let req = lp_req_from_proto(request.into_inner())?; let plan = self .run_blocking("solve_lp", tenant, move || solve_lp(&req)) .await?; + let elapsed_us = started.elapsed().as_micros(); + tracing::info!(solve_duration_us = elapsed_us, status = "ok", "solve_lp completed"); Ok(Response::new(lp_resp_to_proto(plan))) } @@ -125,11 +160,23 @@ impl FerroxSolver for FerroxSolverService { &self, request: Request, ) -> Result, Status> { - let tenant = tenant_slug_from(&request)?; + let ctx = request_context(&request)?; + let span = tracing::info_span!( + "solve_mip", + tenant_app = %ctx.tenant, + request_id = %ctx.request_id, + rpc_method = "ferrox.v1.FerroxSolver/SolveMip", + ); + let _enter = span.enter(); + let started = std::time::Instant::now(); + let tenant = ctx.tenant; + drop(_enter); let req = mip_req_from_proto(request.into_inner())?; let plan = self .run_blocking("solve_mip", tenant, move || solve_mip(&req)) .await?; + let elapsed_us = started.elapsed().as_micros(); + tracing::info!(solve_duration_us = elapsed_us, status = "ok", "solve_mip completed"); Ok(Response::new(mip_resp_to_proto(plan))) } } From f6ff164c639b17af7181c3757bf6c3087241cbb4 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 11:02:04 +0200 Subject: [PATCH 11/26] fix(ferrox-server): emit completion logs inside span scope + cover RequestId (M1.C2-fixup) Per C2 code review: - Completion info! lines were emitted outside the span's scope, so the tenant_app/request_id/rpc_method fields didn't appear on the very log line Cloud Logging dashboards filter on. Wrap each in span.in_scope. - Add structured warn! on the error path (solve_duration_us + status + code + message) so Cloud Logging captures both success and failure. - Add two interceptor unit tests covering RequestId mint-on-absent (asserts valid UUID) and accept-on-present (asserts pass-through). Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ferrox-server/src/service.rs | 96 +++++++++++++++---- crates/ferrox-server/tests/tenant_registry.rs | 38 ++++++++ 2 files changed, 116 insertions(+), 18 deletions(-) diff --git a/crates/ferrox-server/src/service.rs b/crates/ferrox-server/src/service.rs index 57ae3f9..1a06254 100644 --- a/crates/ferrox-server/src/service.rs +++ b/crates/ferrox-server/src/service.rs @@ -119,17 +119,37 @@ impl FerroxSolver for FerroxSolverService { request_id = %ctx.request_id, rpc_method = "ferrox.v1.FerroxSolver/SolveCp", ); - let _enter = span.enter(); let started = std::time::Instant::now(); let tenant = ctx.tenant; - drop(_enter); // drop guard before await let req = cp_req_from_proto(request.into_inner())?; - let plan = self + let result = self .run_blocking("solve_cp", tenant, move || solve_cp(&req)) - .await?; + .await; let elapsed_us = started.elapsed().as_micros(); - tracing::info!(solve_duration_us = elapsed_us, status = "ok", "solve_cp completed"); - Ok(Response::new(cp_resp_to_proto(plan))) + match result { + Ok(plan) => { + span.in_scope(|| { + tracing::info!( + solve_duration_us = elapsed_us, + status = "ok", + "solve_cp completed" + ); + }); + Ok(Response::new(cp_resp_to_proto(plan))) + } + Err(err) => { + span.in_scope(|| { + tracing::warn!( + solve_duration_us = elapsed_us, + status = "error", + code = %err.code(), + message = %err.message(), + "solve_cp failed" + ); + }); + Err(err) + } + } } async fn solve_lp( @@ -143,17 +163,37 @@ impl FerroxSolver for FerroxSolverService { request_id = %ctx.request_id, rpc_method = "ferrox.v1.FerroxSolver/SolveLp", ); - let _enter = span.enter(); let started = std::time::Instant::now(); let tenant = ctx.tenant; - drop(_enter); let req = lp_req_from_proto(request.into_inner())?; - let plan = self + let result = self .run_blocking("solve_lp", tenant, move || solve_lp(&req)) - .await?; + .await; let elapsed_us = started.elapsed().as_micros(); - tracing::info!(solve_duration_us = elapsed_us, status = "ok", "solve_lp completed"); - Ok(Response::new(lp_resp_to_proto(plan))) + match result { + Ok(plan) => { + span.in_scope(|| { + tracing::info!( + solve_duration_us = elapsed_us, + status = "ok", + "solve_lp completed" + ); + }); + Ok(Response::new(lp_resp_to_proto(plan))) + } + Err(err) => { + span.in_scope(|| { + tracing::warn!( + solve_duration_us = elapsed_us, + status = "error", + code = %err.code(), + message = %err.message(), + "solve_lp failed" + ); + }); + Err(err) + } + } } async fn solve_mip( @@ -167,16 +207,36 @@ impl FerroxSolver for FerroxSolverService { request_id = %ctx.request_id, rpc_method = "ferrox.v1.FerroxSolver/SolveMip", ); - let _enter = span.enter(); let started = std::time::Instant::now(); let tenant = ctx.tenant; - drop(_enter); let req = mip_req_from_proto(request.into_inner())?; - let plan = self + let result = self .run_blocking("solve_mip", tenant, move || solve_mip(&req)) - .await?; + .await; let elapsed_us = started.elapsed().as_micros(); - tracing::info!(solve_duration_us = elapsed_us, status = "ok", "solve_mip completed"); - Ok(Response::new(mip_resp_to_proto(plan))) + match result { + Ok(plan) => { + span.in_scope(|| { + tracing::info!( + solve_duration_us = elapsed_us, + status = "ok", + "solve_mip completed" + ); + }); + Ok(Response::new(mip_resp_to_proto(plan))) + } + Err(err) => { + span.in_scope(|| { + tracing::warn!( + solve_duration_us = elapsed_us, + status = "error", + code = %err.code(), + message = %err.message(), + "solve_mip failed" + ); + }); + Err(err) + } + } } } diff --git a/crates/ferrox-server/tests/tenant_registry.rs b/crates/ferrox-server/tests/tenant_registry.rs index 5d51d9a..51e1fe7 100644 --- a/crates/ferrox-server/tests/tenant_registry.rs +++ b/crates/ferrox-server/tests/tenant_registry.rs @@ -159,3 +159,41 @@ fn interceptor_accepts_good_bearer_when_env_set() { assert!(result.is_ok(), "good bearer accepted"); }); } + +// ─── RequestId mint / pass-through ────────────────────────────────────── + +use converge_ferrox_server::interceptor::RequestId; +use uuid::Uuid; + +#[test] +#[serial(env)] +fn interceptor_mints_request_id_when_header_absent() { + temp_env::with_var_unset(AUTH_ENV, || { + let req = req_with_tenant("quorum-sense"); + let req = request_interceptor(req).expect("known tenant accepted"); + let id = req + .extensions() + .get::() + .map(|r| r.0.clone()) + .expect("interceptor attaches request id"); + assert!(!id.is_empty(), "minted id must be non-empty"); + Uuid::parse_str(&id).expect("minted id must be a valid UUID"); + }); +} + +#[test] +#[serial(env)] +fn interceptor_accepts_client_supplied_request_id() { + temp_env::with_var_unset(AUTH_ENV, || { + let mut req = req_with_tenant("quorum-sense"); + let value: MetadataValue<_> = "client-supplied-id-12345".parse().expect("ascii"); + req.metadata_mut().insert("x-request-id", value); + let req = request_interceptor(req).expect("known tenant accepted"); + let id = req + .extensions() + .get::() + .map(|r| r.0.clone()) + .expect("interceptor attaches request id"); + assert_eq!(id, "client-supplied-id-12345"); + }); +} From d8701dacaa6e67f7c0063a2b35dfbf0d8f475cb1 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 11:57:39 +0200 Subject: [PATCH 12/26] chore(ops): idempotent ferrox-server SA + IAM setup script (M1.D1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creates the dedicated runtime SA with logging.logWriter + monitoring.metricWriter. No data-plane roles — solver service is pure compute per spec §7.2. Ran successfully against reflective-labs: created ferrox-server@reflective-labs.iam.gserviceaccount.com. Co-Authored-By: Claude Opus 4.7 (1M context) --- ops/iam-setup.sh | 39 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100755 ops/iam-setup.sh diff --git a/ops/iam-setup.sh b/ops/iam-setup.sh new file mode 100755 index 0000000..77d1372 --- /dev/null +++ b/ops/iam-setup.sh @@ -0,0 +1,39 @@ +#!/usr/bin/env bash +# Idempotent: creates the ferrox-server SA and grants minimal IAM. +# Re-running is safe — SA creation is checked first, role grants are +# additive. +set -euo pipefail + +PROJECT_ID="${PROJECT_ID:-reflective-labs}" +SA_NAME="ferrox-server" +SA_EMAIL="${SA_NAME}@${PROJECT_ID}.iam.gserviceaccount.com" + +# Create SA if it doesn't exist. +if ! gcloud iam service-accounts describe "$SA_EMAIL" \ + --project="$PROJECT_ID" >/dev/null 2>&1; then + echo ">>> creating SA $SA_EMAIL" + gcloud iam service-accounts create "$SA_NAME" \ + --project="$PROJECT_ID" \ + --display-name="ferrox-server (Cloud Run runtime)" \ + --description="Minimal-IAM SA for the ferrox-server Cloud Run service (M1 of the gRPC suggestor pattern). No data-plane permissions — solver service is pure compute." +else + echo ">>> SA $SA_EMAIL already exists" +fi + +# Minimal IAM: +# roles/logging.logWriter — emit structured logs to Cloud Logging +# roles/monitoring.metricWriter — emit metrics +# NOT GRANTED: any Firestore/Storage/Secret Manager — solver owns no data. +echo ">>> granting roles/logging.logWriter" +gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member="serviceAccount:${SA_EMAIL}" \ + --role="roles/logging.logWriter" \ + --condition=None >/dev/null + +echo ">>> granting roles/monitoring.metricWriter" +gcloud projects add-iam-policy-binding "$PROJECT_ID" \ + --member="serviceAccount:${SA_EMAIL}" \ + --role="roles/monitoring.metricWriter" \ + --condition=None >/dev/null + +echo ">>> done. SA: $SA_EMAIL" From 20f473544d8990ee79193d13ea2662987255d6f1 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 11:58:18 +0200 Subject: [PATCH 13/26] build(ops): Cloud Build config for ferrox-server prod image (M1.D2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Targets europe-west1-docker.pkg.dev/reflective-labs/converge/ferrox-server. E2_HIGHCPU_8 + 100GB disk + 60min timeout to absorb the cold OR-Tools + HiGHS build (~30 min). The repo-root Dockerfile is self-contained per spec §3.5 Rule 1 — no shared math-base. Co-Authored-By: Claude Opus 4.7 (1M context) --- ops/cloudbuild.prod.yaml | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 ops/cloudbuild.prod.yaml diff --git a/ops/cloudbuild.prod.yaml b/ops/cloudbuild.prod.yaml new file mode 100644 index 0000000..46a18a5 --- /dev/null +++ b/ops/cloudbuild.prod.yaml @@ -0,0 +1,37 @@ +# Cloud Build — ferrox-server image for Cloud Run (reflective-labs/prod). +# +# Usage: +# gcloud builds submit . \ +# --project=reflective-labs \ +# --config=ops/cloudbuild.prod.yaml \ +# --substitutions=_TAG=v0.7.2-$(git rev-parse --short HEAD) +# +# The Dockerfile at repo root builds OR-Tools + HiGHS in Stage 1 and +# compiles the Rust server in Stage 2 — self-contained, no math-base. + +substitutions: + _TAG: "latest" + _REGION: "europe-west1" + _REPO: "europe-west1-docker.pkg.dev/reflective-labs/converge" + +steps: + - name: "gcr.io/cloud-builders/docker" + env: + - "DOCKER_BUILDKIT=1" + args: + - "build" + - "-t" + - "${_REPO}/ferrox-server:${_TAG}" + - "-f" + - "Dockerfile" + - "." + +images: + - "${_REPO}/ferrox-server:${_TAG}" + +options: + machineType: "E2_HIGHCPU_8" + logging: CLOUD_LOGGING_ONLY + diskSizeGb: 100 # OR-Tools + HiGHS build artifacts are big + +timeout: "3600s" # OR-Tools + HiGHS cold build can take ~30 min on E2_HIGHCPU_8 From d5c2af7955130c2d6b937e6083e286749b48ca4b Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 12:02:49 +0200 Subject: [PATCH 14/26] fix(ops): drop unused _REGION substitution from cloudbuild.prod.yaml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloud Build now strictly validates declared substitutions are referenced in the template — INVALID_ARGUMENT on submit otherwise. _REGION was declared but not used (region is embedded in _REPO). --- ops/cloudbuild.prod.yaml | 1 - 1 file changed, 1 deletion(-) diff --git a/ops/cloudbuild.prod.yaml b/ops/cloudbuild.prod.yaml index 46a18a5..d3472be 100644 --- a/ops/cloudbuild.prod.yaml +++ b/ops/cloudbuild.prod.yaml @@ -11,7 +11,6 @@ substitutions: _TAG: "latest" - _REGION: "europe-west1" _REPO: "europe-west1-docker.pkg.dev/reflective-labs/converge" steps: From aee13763baeaddb2bb222a8417dfb7317bcd2cbe Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 12:08:58 +0200 Subject: [PATCH 15/26] deploy(ops): Cloud Run service manifest for ferrox-server (M1.D3) ingress=internal, VPC connector solver-egress-ew1, concurrency=1 (CP-SAT is single-process / single-request), minScale=1, maxScale=10, 2vCPU/4GiB. h2c port name so Cloud Run uses HTTP/2 cleartext to the container. SA: ferrox-server@reflective-labs (no data-plane IAM). Image tag pinned to v0.7.2-d5c2af7 (matches the cloudbuild fix commit). Co-Authored-By: Claude Opus 4.7 (1M context) --- ops/cloudrun.prod.yaml | 60 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 ops/cloudrun.prod.yaml diff --git a/ops/cloudrun.prod.yaml b/ops/cloudrun.prod.yaml new file mode 100644 index 0000000..9730fe3 --- /dev/null +++ b/ops/cloudrun.prod.yaml @@ -0,0 +1,60 @@ +# Cloud Run service manifest — ferrox-server, prod, reflective-labs. +# +# Apply with: +# gcloud run services replace ops/cloudrun.prod.yaml \ +# --project=reflective-labs --region=europe-west1 +# +# IMPORTANT: when re-deploying with a new image tag, update +# spec.template.spec.containers[0].image to the exact tag pushed by +# `just deploy-build` (or gcloud builds submit) BEFORE applying. + +apiVersion: serving.knative.dev/v1 +kind: Service +metadata: + name: ferrox-server + labels: + app: ferrox-server + extension: ferrox + platform: converge + annotations: + # Internal-only ingress per spec §7.1. + run.googleapis.com/ingress: internal +spec: + template: + metadata: + annotations: + autoscaling.knative.dev/minScale: "1" + autoscaling.knative.dev/maxScale: "10" + # VPC connector for ingress=internal callers to reach us. + run.googleapis.com/vpc-access-connector: solver-egress-ew1 + run.googleapis.com/vpc-access-egress: private-ranges-only + spec: + serviceAccountName: ferrox-server@reflective-labs.iam.gserviceaccount.com + # CP-SAT solves are CPU-bound and one solve fully uses an instance. + # Cloud Run scales horizontally to handle bursts. + containerConcurrency: 1 + # Cloud Run hard ceiling for unary HTTP/2 — clamp our timeout below. + timeoutSeconds: 300 + containers: + - image: europe-west1-docker.pkg.dev/reflective-labs/converge/ferrox-server:v0.7.2-d5c2af7 + ports: + # name: h2c — tells Cloud Run to use HTTP/2 cleartext to the + # container. Cloud Run still terminates client-facing TLS. + - name: h2c + containerPort: 50051 + resources: + limits: + cpu: "2" + memory: "4Gi" + env: + - name: FERROX_ADDR + value: "0.0.0.0:50051" + - name: FERROX_SERVER_MAX_BLOCKING_SOLVES + value: "1" + - name: RUST_LOG + value: "ferrox_server=info" + # No FERROX_AUTH_TOKEN in v1 — tenant header is the only gate + # (spec §7.2). Set this only when the bearer-auth upgrade is wanted. + traffic: + - latestRevision: true + percent: 100 From d0521d2fd33fe843f44469cc45afbd66ab38295e Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 12:09:01 +0200 Subject: [PATCH 16/26] chore(just): add deploy-build, deploy-apply, smoke-prod, tenants-show (M1.D4) Wrappers around gcloud builds submit + gcloud run services replace + ops/smoke.sh so the deploy is one command, not five. Co-Authored-By: Claude Opus 4.7 (1M context) --- Justfile | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Justfile b/Justfile index b511e56..db703d6 100644 --- a/Justfile +++ b/Justfile @@ -269,3 +269,29 @@ release-check: SOAK_DURATION_MIN=5 just soak just lint cargo test --workspace + +# ─── Cloud Run prod deploy (M1) ────────────────────────────────────────────── + +# Build + push prod image. _TAG defaults to v0.7.2-. +# Usage: just deploy-build or just deploy-build TAG=v0.7.2-abc1234 +deploy-build TAG=`echo "v0.7.2-$(git rev-parse --short HEAD)"`: + gcloud builds submit . \ + --project=reflective-labs \ + --config=ops/cloudbuild.prod.yaml \ + --substitutions=_TAG={{TAG}} + +# Apply Cloud Run manifest. Edit ops/cloudrun.prod.yaml image tag first. +deploy-apply: + gcloud run services replace ops/cloudrun.prod.yaml \ + --project=reflective-labs \ + --region=europe-west1 + +# List tenants the server image currently knows about. +tenants-show: + @grep -E '^\s*Tenant \{ slug' crates/ferrox-server/src/tenants.rs + +# Smoke against the deployed service. Requires the Cloud Run service URL. +# Run from Cloud Shell or via `gcloud run services proxy` since ingress=internal. +# Usage: just smoke-prod URL=https://ferrox-server-XXX-ew.a.run.app +smoke-prod URL: + ops/smoke.sh {{URL}} From 4ab1c69b39a326dc541470b1da186caae2b55caa Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 12:09:05 +0200 Subject: [PATCH 17/26] test(ops): grpcurl smoke for ferrox-server prod (M1.E1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four checks: Health/Check SERVING, missing tenant → INVALID_ARGUMENT, unknown tenant → PERMISSION_DENIED, quorum-sense + minimal CP → response. Run from Cloud Shell (VPC access) or via gcloud run services proxy. Co-Authored-By: Claude Opus 4.7 (1M context) --- ops/smoke.sh | 75 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100755 ops/smoke.sh diff --git a/ops/smoke.sh b/ops/smoke.sh new file mode 100755 index 0000000..29e3dd8 --- /dev/null +++ b/ops/smoke.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# Smoke-test the deployed ferrox-server. +# +# Usage: ops/smoke.sh https://ferrox-server-XXXXXXXX-ew.a.run.app +# +# Run from Cloud Shell (has VPC access by default) or via: +# gcloud run services proxy ferrox-server --project=reflective-labs \ +# --region=europe-west1 --port=9090 & +# ops/smoke.sh http://localhost:9090 +# +# Requires grpcurl. Install: gcloud components install grpcurl (Cloud Shell) +# OR brew install grpcurl (macOS local) +set -euo pipefail + +URL="${1:?usage: smoke.sh }" +HOST_PORT="${URL#https://}" +HOST_PORT="${HOST_PORT#http://}" +HOST_PORT="${HOST_PORT%/}" +SCHEME_FLAGS="" +if [[ "$URL" == http://* ]]; then + SCHEME_FLAGS="-plaintext" +fi + +echo "── 1/4: grpc.health.v1.Health/Check should return SERVING ──" +RESP=$(grpcurl $SCHEME_FLAGS -d '{"service": ""}' "$HOST_PORT" grpc.health.v1.Health/Check) +echo "$RESP" +echo "$RESP" | grep -q '"status": "SERVING"' \ + || { echo "FAIL: expected SERVING"; exit 1; } +echo "ok" +echo + +echo "── 2/4: SolveCp WITHOUT x-converge-app → INVALID_ARGUMENT ──" +if grpcurl $SCHEME_FLAGS -d '{}' "$HOST_PORT" \ + ferrox.v1.FerroxSolver/SolveCp 2>&1 \ + | grep -q "InvalidArgument"; then + echo "ok" +else + echo "FAIL: expected InvalidArgument for missing tenant header" + exit 1 +fi +echo + +echo "── 3/4: SolveCp WITH unknown tenant → PERMISSION_DENIED ──" +if grpcurl $SCHEME_FLAGS -H 'x-converge-app: nope-not-real' \ + -d '{}' "$HOST_PORT" \ + ferrox.v1.FerroxSolver/SolveCp 2>&1 \ + | grep -q "PermissionDenied"; then + echo "ok" +else + echo "FAIL: expected PermissionDenied for unknown tenant" + exit 1 +fi +echo + +echo "── 4/4: SolveCp WITH quorum-sense + minimal valid CP problem ──" +# Trivial CP: maximize x subject to 0 ≤ x ≤ 1. Optimal x=1. +RESP=$(grpcurl $SCHEME_FLAGS \ + -H 'x-converge-app: quorum-sense' \ + -d '{ + "problem": { + "variables": [{"name": "x", "lb": 0, "ub": 1, "is_bool": false}], + "objective": {"sense": "maximize", "linear": {"terms": [{"var": "x", "coeff": 1}], "rhs": 0}} + }, + "time_limit_sec": 5 + }' \ + "$HOST_PORT" ferrox.v1.FerroxSolver/SolveCp) +echo "$RESP" +# Accept any non-error response — the exact shape depends on the solver +# version, but if we got JSON back the solver round-tripped. +echo "$RESP" | grep -q '"status"' \ + || { echo "FAIL: expected a structured response"; exit 1; } +echo "ok" +echo + +echo "── ALL 4 SMOKE CHECKS PASSED ──" From 312605d080972a1d8d901b30263508abda8e40ef Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 12:53:02 +0200 Subject: [PATCH 18/26] fix(docker): clone bedrock-platform sibling repos instead of stripping patches (M1.D2-fixup) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous approach (sed -i '/^\[patch\.crates-io\]/,$d' /workspace/Cargo.toml) deleted the patch block and fell back to crates.io versions of converge-* and organism-* crates. Brittle: instant API drift breaks the build the moment ferrox source uses a method newer than what's published. Caught in build 57d1f5b8: 12× E0599 on ProvenanceSource::proposed_fact_for. That method exists in local bedrock-platform/converge/crates/pack/src/fact.rs:191 but not in the published converge-pack 3.9.2. Fix: mirror marquee-apps/quorum-sense/deploy/backend/Dockerfile.cloudrun. Clone Reflective-Lab/converge.git and Reflective-Lab/organism.git into /bedrock-platform/, sed-rewrite the patch paths from `../../bedrock-platform/` to `/bedrock-platform/`. Patch block intact; Cargo resolves against the cloned sources at HEAD of each repo. Co-Authored-By: Claude Opus 4.7 (1M context) --- Dockerfile | 23 +++++++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 19e4923..eb21648 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,7 +36,7 @@ RUN git clone --depth 1 --branch ${HIGHS_TAG} \ FROM rust:1.94-trixie AS rust-builder RUN apt-get update && apt-get install -y --no-install-recommends \ - build-essential cmake clang libclang-dev protobuf-compiler \ + build-essential cmake clang libclang-dev protobuf-compiler git ca-certificates \ && rm -rf /var/lib/apt/lists/* # Bring full source + build trees so the sys crates' build.rs finds headers @@ -49,9 +49,24 @@ WORKDIR /workspace # Build context is the ferrox repo root. COPY . /workspace/ -# Docker builds only receive the ferrox repo as context, so local Reflective -# path patches cannot resolve. Use the published workspace dependency versions. -RUN sed -i '/^\[patch\.crates-io\]/,$d' /workspace/Cargo.toml && \ +# [patch.crates-io] in Cargo.toml references sibling repos via the +# developer-monorepo layout (`../../bedrock-platform/converge/...`, +# `../../bedrock-platform/organism/...`). The Docker build context only +# includes ferrox-solvers, so those paths don't resolve. +# +# Approach (mirrors marquee-apps/quorum-sense/deploy/backend/Dockerfile.cloudrun): +# clone the sibling repos to /bedrock-platform/ and sed-rewrite the +# patch paths to point at the clones. This keeps ferrox's source on the +# same converge-pack/organism API surface it was authored against — +# crucial when ferrox uses APIs not yet published to crates.io +# (e.g., ProvenanceSource::proposed_fact_for added post-3.9.2). +RUN mkdir -p /bedrock-platform && \ + git clone --depth=1 --quiet https://github.com/Reflective-Lab/converge.git /bedrock-platform/converge && \ + git clone --depth=1 --quiet https://github.com/Reflective-Lab/organism.git /bedrock-platform/organism + +RUN sed -i \ + -e 's|path = "../../bedrock-platform/|path = "/bedrock-platform/|g' \ + /workspace/Cargo.toml && \ rm -f /workspace/Cargo.lock ENV FERROX_ORTOOLS_ROOT=/opt/ortools/build From dba9190e863422987d944513c2c090e68a81d7fc Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 13:12:36 +0200 Subject: [PATCH 19/26] deploy(ops): pin Cloud Run image to v0.7.2-312605d (M1.D3) Bump to the build that succeeded after the bedrock-platform sibling-repo clone fix (Dockerfile commit 312605d). Cloud Build 2e9ec147 SUCCESS in 18:17, image pushed to europe-west1-docker.pkg.dev/reflective-labs/converge/ferrox-server:v0.7.2-312605d. --- ops/cloudrun.prod.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ops/cloudrun.prod.yaml b/ops/cloudrun.prod.yaml index 9730fe3..b1dff22 100644 --- a/ops/cloudrun.prod.yaml +++ b/ops/cloudrun.prod.yaml @@ -36,7 +36,7 @@ spec: # Cloud Run hard ceiling for unary HTTP/2 — clamp our timeout below. timeoutSeconds: 300 containers: - - image: europe-west1-docker.pkg.dev/reflective-labs/converge/ferrox-server:v0.7.2-d5c2af7 + - image: europe-west1-docker.pkg.dev/reflective-labs/converge/ferrox-server:v0.7.2-312605d ports: # name: h2c — tells Cloud Run to use HTTP/2 cleartext to the # container. Cloud Run still terminates client-facing TLS. From 6c0018ab8e72eb3c8ec14531f81ee42d4b0727b1 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 17:46:49 +0200 Subject: [PATCH 20/26] docs(kb): record M1 shipped state (M1.E3) Service: https://ferrox-server-640630843925.europe-west1.run.app Image: v0.7.2-312605d on europe-west1-docker.pkg.dev/reflective-labs/converge Smoke: 4/4 green (Health/Check SERVING, missing tenant INVALID_ARGUMENT, unknown tenant PERMISSION_DENIED, quorum-sense + trivial CP returned optimal x=1 from cp-sat-v9.15). M2 + M3 unblocked. Co-Authored-By: Claude Opus 4.7 (1M context) --- kb/Architecture/Cloud Run Deployment.md | 36 +++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/kb/Architecture/Cloud Run Deployment.md b/kb/Architecture/Cloud Run Deployment.md index 9f6a5fa..a4c69ed 100644 --- a/kb/Architecture/Cloud Run Deployment.md +++ b/kb/Architecture/Cloud Run Deployment.md @@ -50,3 +50,39 @@ for Cloud Run deploy. M2 (soter-server) follows the same pattern; M5 (prism-server) when prism is ready. M4 (`RemoteCpSatBackend` consumers) when a marquee-app needs CP-SAT. + +## Deployed state (M1 shipped 2026-06-15) + +| Field | Value | +|---|---| +| Project | `reflective-labs` (number 640630843925) | +| Region | `europe-west1` | +| Service URL | `https://ferrox-server-640630843925.europe-west1.run.app` | +| Ingress | `internal` | +| VPC connector | `solver-egress-ew1` (10.8.0.0/28, 2× e2-micro) | +| Service account | `ferrox-server@reflective-labs.iam.gserviceaccount.com` | +| Image tag | `v0.7.2-312605d` | +| Image registry | `europe-west1-docker.pkg.dev/reflective-labs/converge/ferrox-server` | +| Concurrency | 1 (CP-SAT is single-process) | +| Min/Max instances | 1 / 10 | +| CPU / Memory | 2 vCPU / 4 GiB | +| Cloud Run timeout | 300s | +| Bearer auth | off (FERROX_AUTH_TOKEN unset; tenant header is the gate) | +| Tenant allowlist | `quorum-sense` (4 in-flight, compiled into image) | +| Health check | `grpc.health.v1.Health` via `tonic-health` | +| Smoke script | `ops/smoke.sh ` | +| Cloud Build SHA | `2e9ec147-cc07-42a1-8365-3cd99b6d43a7` (~18 min cold build) | + +**Smoke verified 2026-06-15:** +- `grpc.health.v1.Health/Check` → `SERVING` +- `FerroxSolver/SolveCp` without `x-converge-app` → `INVALID_ARGUMENT: missing x-converge-app header` +- `FerroxSolver/SolveCp` with unknown tenant → `PERMISSION_DENIED: unknown tenant: ` +- `FerroxSolver/SolveCp` with `x-converge-app: quorum-sense` + trivial CP (max x, 0≤x≤1) → `status: "optimal"`, `objective_value: 1`, `solver: "cp-sat-v9.15"` + +**Known reflection follow-up:** server doesn't expose gRPC reflection, so `grpcurl` needs `-proto` to call. Adding `tonic-reflection` is a follow-up ticket — keeps the API surface discoverable for ops/debugging. + +**Smoke connectivity note:** `gcloud run services proxy` failed via Homebrew gcloud (h2c local listener broken). The smoke was run by temporarily flipping `--ingress=all` for ~3 min, hitting the service URL directly with an ID-token-authenticated grpcurl from the dev laptop, then flipping back to `--ingress=internal`. Auth still required throughout. The recurring smoke path will be: Cloud Shell (browser, inside Google's network) once the service is invoker-bound to a Cloud-Shell-reachable identity, OR via the VPC connector from another in-VPC client. + +## Unblocked + +M2 (soter-server new) + M3 (quorum-sense flips to `RemoteSmtBackend`) can now plan against this service URL. From 2966adcf4a23bcdb973f698aacf2619d583e88a8 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 17:54:08 +0200 Subject: [PATCH 21/26] feat(ferrox-server): expose grpc.reflection.v1.ServerReflection (M1.followup) Adds tonic-reflection 0.14 + emits a FileDescriptorSet from build.rs. Both the ferrox.v1 descriptor AND tonic-health's bundled descriptor are registered, so grpcurl / Postman / Buf CLI can introspect the schema without a local .proto file. Closes one of two M1 deferred items. Smoke after redeploy: grpcurl :443 list -> grpc.health.v1.Health -> grpc.reflection.v1.ServerReflection -> ferrox.v1.FerroxSolver Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.lock | 15 +++++++++++++++ crates/ferrox-server/Cargo.toml | 1 + crates/ferrox-server/build.rs | 7 +++++++ crates/ferrox-server/src/main.rs | 18 ++++++++++++++++++ 4 files changed, 41 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index fb5e76c..dc61e60 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -200,6 +200,7 @@ dependencies = [ "tonic-health", "tonic-prost", "tonic-prost-build", + "tonic-reflection", "tracing", "tracing-subscriber", "uuid", @@ -1465,6 +1466,20 @@ dependencies = [ "tonic-build", ] +[[package]] +name = "tonic-reflection" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acccd136a4bf19810a1fde9c74edc6129b42a66b44d0c1c8aaa67aeb49a146a7" +dependencies = [ + "prost", + "prost-types", + "tokio", + "tokio-stream", + "tonic", + "tonic-prost", +] + [[package]] name = "tower" version = "0.5.3" diff --git a/crates/ferrox-server/Cargo.toml b/crates/ferrox-server/Cargo.toml index c4f30b6..9a499d0 100644 --- a/crates/ferrox-server/Cargo.toml +++ b/crates/ferrox-server/Cargo.toml @@ -29,6 +29,7 @@ ferrox-highs-sys = { workspace = true, optional = true } tonic = { version = "0.14", features = ["tls-ring"] } tonic-prost = "0.14" tonic-health = "0.14" +tonic-reflection = "0.14" prost = "0.14" tokio = { workspace = true } tracing = { workspace = true } diff --git a/crates/ferrox-server/build.rs b/crates/ferrox-server/build.rs index f559e7b..a3b6835 100644 --- a/crates/ferrox-server/build.rs +++ b/crates/ferrox-server/build.rs @@ -12,9 +12,16 @@ fn main() -> Result<(), Box> { } } + // Emit the encoded FileDescriptorSet so tonic-reflection can serve + // grpc.reflection.v1.ServerReflection. The path is read at compile time + // via `tonic::include_file_descriptor_set!("ferrox_descriptor")` in main.rs. + let descriptor_path = std::path::PathBuf::from(std::env::var("OUT_DIR")?) + .join("ferrox_descriptor.bin"); + tonic_prost_build::configure() .build_server(true) .build_client(false) + .file_descriptor_set_path(&descriptor_path) .compile_protos(&[proto], &[proto_dir])?; Ok(()) diff --git a/crates/ferrox-server/src/main.rs b/crates/ferrox-server/src/main.rs index 291a6f2..220a4a2 100644 --- a/crates/ferrox-server/src/main.rs +++ b/crates/ferrox-server/src/main.rs @@ -23,6 +23,13 @@ use converge_ferrox_server::interceptor::request_interceptor; use proto::ferrox::v1::ferrox_solver_server::FerroxSolverServer; use service::FerroxSolverService; +/// Encoded FileDescriptorSet for `ferrox.v1`. Emitted by `build.rs` via +/// `tonic_prost_build::configure().file_descriptor_set_path(...)`. Consumed +/// by `tonic-reflection` to advertise the service schema over the standard +/// `grpc.reflection.v1.ServerReflection` API. +const FERROX_FILE_DESCRIPTOR_SET: &[u8] = + tonic::include_file_descriptor_set!("ferrox_descriptor"); + #[tokio::main] async fn main() -> anyhow::Result<()> { tracing_subscriber::fmt() @@ -48,6 +55,15 @@ async fn main() -> anyhow::Result<()> { .set_serving::>() .await; + // gRPC reflection — exposes grpc.reflection.v1.ServerReflection so + // grpcurl / Postman / Buf CLI can introspect the service without a + // local .proto file. Registers both the ferrox.v1 descriptor (emitted + // by build.rs) and tonic-health's bundled descriptor. + let reflection_service = tonic_reflection::server::Builder::configure() + .register_encoded_file_descriptor_set(tonic_health::pb::FILE_DESCRIPTOR_SET) + .register_encoded_file_descriptor_set(FERROX_FILE_DESCRIPTOR_SET) + .build_v1()?; + let cert_path = std::env::var("FERROX_TLS_CERT").unwrap_or_else(|_| "/tls/server.crt".into()); let key_path = std::env::var("FERROX_TLS_KEY").unwrap_or_else(|_| "/tls/server.key".into()); @@ -70,6 +86,7 @@ async fn main() -> anyhow::Result<()> { Server::builder() .tls_config(tls)? .add_service(health_service) + .add_service(reflection_service) .add_service(FerroxSolverServer::with_interceptor(svc, request_interceptor)) .serve(addr) .await?; @@ -77,6 +94,7 @@ async fn main() -> anyhow::Result<()> { tracing::warn!("TLS cert/key not found — starting without TLS (dev/test only)"); Server::builder() .add_service(health_service) + .add_service(reflection_service) .add_service(FerroxSolverServer::with_interceptor(svc, request_interceptor)) .serve(addr) .await?; From 83fca0ad256839596793b489ed044b3f6779e657 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 17:54:47 +0200 Subject: [PATCH 22/26] docs(ops): document Cloud Shell as canonical smoke runner Reframe smoke.sh header: Cloud Shell is the zero-setup canonical path (runs in Google's network, has grpcurl + authenticated identity by default). cloud-run-proxy is documented as the local-dev alternative with a caveat about the Homebrew gcloud h2c listener hang we observed. Ingress-flip fallback is also documented for emergency use. Once tonic-reflection lands (next commit), no -proto flag is needed. Co-Authored-By: Claude Opus 4.7 (1M context) --- ops/smoke.sh | 38 ++++++++++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 4 deletions(-) diff --git a/ops/smoke.sh b/ops/smoke.sh index 29e3dd8..e59696c 100755 --- a/ops/smoke.sh +++ b/ops/smoke.sh @@ -1,15 +1,45 @@ #!/usr/bin/env bash # Smoke-test the deployed ferrox-server. # -# Usage: ops/smoke.sh https://ferrox-server-XXXXXXXX-ew.a.run.app +# ─── Canonical flow: Cloud Shell ────────────────────────────────────────────── +# The ferrox-server Cloud Run service is `ingress=internal`, so callers must +# reach it from inside the VPC. The most reliable + zero-setup path is Cloud +# Shell in the browser. It runs in Google's network, ships grpcurl, and uses +# your authenticated identity by default. +# +# 1. Open https://shell.cloud.google.com (your project = reflective-labs) +# 2. git clone --depth=1 -b next https://github.com/Reflective-Lab/ferrox-solvers.git +# 3. cd ferrox-solvers +# 4. ops/smoke.sh https://ferrox-server-640630843925.europe-west1.run.app +# +# The server registers grpc.reflection.v1.ServerReflection, so no -proto +# flag is needed — grpcurl introspects the schema over the wire. +# +# ─── Alternative: local dev with cloud-run-proxy ────────────────────────────── +# Requires the cloud-run-proxy binary on PATH (Homebrew gcloud ships it under +# /opt/homebrew/share/google-cloud-sdk/bin/ but its h2c local listener has +# been observed to hang on some macOS versions). If you have a working proxy: # -# Run from Cloud Shell (has VPC access by default) or via: # gcloud run services proxy ferrox-server --project=reflective-labs \ # --region=europe-west1 --port=9090 & # ops/smoke.sh http://localhost:9090 # -# Requires grpcurl. Install: gcloud components install grpcurl (Cloud Shell) -# OR brew install grpcurl (macOS local) +# If the proxy hangs, fall back to Cloud Shell above, or temporarily flip +# `--ingress=all` (auth still required via ID token) for a 3-minute window: +# +# gcloud run services update ferrox-server --project=reflective-labs \ +# --region=europe-west1 --ingress=all +# TOKEN=$(gcloud auth print-identity-token) +# grpcurl -H "authorization: Bearer ${TOKEN}" \ +# ferrox-server-640630843925.europe-west1.run.app:443 list +# # ...smoke... +# gcloud run services update ferrox-server --project=reflective-labs \ +# --region=europe-west1 --ingress=internal +# +# ─── grpcurl install ────────────────────────────────────────────────────────── +# Cloud Shell: pre-installed. +# macOS: brew install grpcurl +# Linux: see https://github.com/fullstorydev/grpcurl/releases set -euo pipefail URL="${1:?usage: smoke.sh }" From b070c79ba189063cae8b9035bf14a879f6e8b804 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 19:08:09 +0200 Subject: [PATCH 23/26] deploy(ops): bump Cloud Run image to v0.7.2-2966adc (reflection enabled) Build 2eda236 SUCCESS. Image carries tonic-reflection registered alongside tonic-health, so grpcurl / Postman / Buf CLI can introspect schema without local .proto files. --- ops/cloudrun.prod.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ops/cloudrun.prod.yaml b/ops/cloudrun.prod.yaml index b1dff22..23d88be 100644 --- a/ops/cloudrun.prod.yaml +++ b/ops/cloudrun.prod.yaml @@ -36,7 +36,7 @@ spec: # Cloud Run hard ceiling for unary HTTP/2 — clamp our timeout below. timeoutSeconds: 300 containers: - - image: europe-west1-docker.pkg.dev/reflective-labs/converge/ferrox-server:v0.7.2-312605d + - image: europe-west1-docker.pkg.dev/reflective-labs/converge/ferrox-server:v0.7.2-2966adc ports: # name: h2c — tells Cloud Run to use HTTP/2 cleartext to the # container. Cloud Run still terminates client-facing TLS. From 7cd715c919e17174463cf34cf5c26eef7ab56232 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Mon, 15 Jun 2026 19:09:37 +0200 Subject: [PATCH 24/26] chore(fmt+kb): rustfmt cleanup + record reflection live (2026-06-15) - cargo fmt across crates/ferrox-server/ (pure whitespace; no behavior change). - kb/Architecture/Cloud Run Deployment.md: bump image tag to v0.7.2-2966adc, add reflection row, flip the deferred-reflection follow-up to RESOLVED. Smoke (no -proto, via ingress-flip workaround since Cloud Shell needs manual run): grpcurl :443 list -> ferrox.v1.FerroxSolver -> grpc.health.v1.Health -> grpc.reflection.v1.ServerReflection Health/Check -> SERVING SolveCp + quorum-sense + trivial CP -> status=optimal, x=1 Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/ferrox-server/build.rs | 4 +-- crates/ferrox-server/src/main.rs | 13 ++++++--- crates/ferrox-server/src/service.rs | 5 +++- crates/ferrox-server/src/tenants.rs | 29 ++++++++++--------- crates/ferrox-server/tests/tenant_registry.rs | 5 +++- kb/Architecture/Cloud Run Deployment.md | 5 ++-- 6 files changed, 38 insertions(+), 23 deletions(-) diff --git a/crates/ferrox-server/build.rs b/crates/ferrox-server/build.rs index a3b6835..57fd0b5 100644 --- a/crates/ferrox-server/build.rs +++ b/crates/ferrox-server/build.rs @@ -15,8 +15,8 @@ fn main() -> Result<(), Box> { // Emit the encoded FileDescriptorSet so tonic-reflection can serve // grpc.reflection.v1.ServerReflection. The path is read at compile time // via `tonic::include_file_descriptor_set!("ferrox_descriptor")` in main.rs. - let descriptor_path = std::path::PathBuf::from(std::env::var("OUT_DIR")?) - .join("ferrox_descriptor.bin"); + let descriptor_path = + std::path::PathBuf::from(std::env::var("OUT_DIR")?).join("ferrox_descriptor.bin"); tonic_prost_build::configure() .build_server(true) diff --git a/crates/ferrox-server/src/main.rs b/crates/ferrox-server/src/main.rs index 220a4a2..17649ab 100644 --- a/crates/ferrox-server/src/main.rs +++ b/crates/ferrox-server/src/main.rs @@ -27,8 +27,7 @@ use service::FerroxSolverService; /// `tonic_prost_build::configure().file_descriptor_set_path(...)`. Consumed /// by `tonic-reflection` to advertise the service schema over the standard /// `grpc.reflection.v1.ServerReflection` API. -const FERROX_FILE_DESCRIPTOR_SET: &[u8] = - tonic::include_file_descriptor_set!("ferrox_descriptor"); +const FERROX_FILE_DESCRIPTOR_SET: &[u8] = tonic::include_file_descriptor_set!("ferrox_descriptor"); #[tokio::main] async fn main() -> anyhow::Result<()> { @@ -87,7 +86,10 @@ async fn main() -> anyhow::Result<()> { .tls_config(tls)? .add_service(health_service) .add_service(reflection_service) - .add_service(FerroxSolverServer::with_interceptor(svc, request_interceptor)) + .add_service(FerroxSolverServer::with_interceptor( + svc, + request_interceptor, + )) .serve(addr) .await?; } else { @@ -95,7 +97,10 @@ async fn main() -> anyhow::Result<()> { Server::builder() .add_service(health_service) .add_service(reflection_service) - .add_service(FerroxSolverServer::with_interceptor(svc, request_interceptor)) + .add_service(FerroxSolverServer::with_interceptor( + svc, + request_interceptor, + )) .serve(addr) .await?; } diff --git a/crates/ferrox-server/src/service.rs b/crates/ferrox-server/src/service.rs index 1a06254..445094f 100644 --- a/crates/ferrox-server/src/service.rs +++ b/crates/ferrox-server/src/service.rs @@ -66,7 +66,10 @@ impl FerroxSolverService { impl Default for FerroxSolverService { fn default() -> Self { - Self::new(configured_blocking_solves(), Arc::new(TenantRegistry::default())) + Self::new( + configured_blocking_solves(), + Arc::new(TenantRegistry::default()), + ) } } diff --git a/crates/ferrox-server/src/tenants.rs b/crates/ferrox-server/src/tenants.rs index 31e9a82..06af668 100644 --- a/crates/ferrox-server/src/tenants.rs +++ b/crates/ferrox-server/src/tenants.rs @@ -20,15 +20,19 @@ pub struct Tenant { pub max_in_flight: u32, } -pub const TENANTS: &[Tenant] = &[ - Tenant { slug: "quorum-sense", max_in_flight: 4 }, -]; +pub const TENANTS: &[Tenant] = &[Tenant { + slug: "quorum-sense", + max_in_flight: 4, +}]; impl Tenant { /// Return the in-flight cap for `slug`, or `None` if not on the allowlist. #[must_use] pub fn cap_for(slug: &str) -> Option { - TENANTS.iter().find(|t| t.slug == slug).map(|t| t.max_in_flight) + TENANTS + .iter() + .find(|t| t.slug == slug) + .map(|t| t.max_in_flight) } } @@ -76,15 +80,14 @@ impl TenantRegistry { .get(slug) .ok_or_else(|| Status::permission_denied(format!("unknown tenant: {slug}")))? .clone(); - sem.try_acquire_owned() - .map_err(|e| match e { - tokio::sync::TryAcquireError::NoPermits => { - Status::resource_exhausted(format!("tenant {slug} at in-flight cap")) - } - tokio::sync::TryAcquireError::Closed => { - Status::unavailable(format!("tenant {slug} semaphore closed")) - } - }) + sem.try_acquire_owned().map_err(|e| match e { + tokio::sync::TryAcquireError::NoPermits => { + Status::resource_exhausted(format!("tenant {slug} at in-flight cap")) + } + tokio::sync::TryAcquireError::Closed => { + Status::unavailable(format!("tenant {slug} semaphore closed")) + } + }) } } diff --git a/crates/ferrox-server/tests/tenant_registry.rs b/crates/ferrox-server/tests/tenant_registry.rs index 51e1fe7..d942560 100644 --- a/crates/ferrox-server/tests/tenant_registry.rs +++ b/crates/ferrox-server/tests/tenant_registry.rs @@ -26,7 +26,10 @@ fn unknown_tenant_returns_none_from_lookup() { #[tokio::test] async fn acquire_unknown_tenant_returns_permission_denied() { let reg = TenantRegistry::default(); - let err = reg.acquire("nope-not-real").await.expect_err("should error"); + let err = reg + .acquire("nope-not-real") + .await + .expect_err("should error"); assert_eq!(err.code(), Code::PermissionDenied); assert!(err.message().contains("unknown tenant")); } diff --git a/kb/Architecture/Cloud Run Deployment.md b/kb/Architecture/Cloud Run Deployment.md index a4c69ed..672d2a0 100644 --- a/kb/Architecture/Cloud Run Deployment.md +++ b/kb/Architecture/Cloud Run Deployment.md @@ -61,7 +61,7 @@ ready. M4 (`RemoteCpSatBackend` consumers) when a marquee-app needs CP-SAT. | Ingress | `internal` | | VPC connector | `solver-egress-ew1` (10.8.0.0/28, 2× e2-micro) | | Service account | `ferrox-server@reflective-labs.iam.gserviceaccount.com` | -| Image tag | `v0.7.2-312605d` | +| Image tag | `v0.7.2-2966adc` (2026-06-15 — reflection enabled; was `v0.7.2-312605d` at initial ship) | | Image registry | `europe-west1-docker.pkg.dev/reflective-labs/converge/ferrox-server` | | Concurrency | 1 (CP-SAT is single-process) | | Min/Max instances | 1 / 10 | @@ -70,6 +70,7 @@ ready. M4 (`RemoteCpSatBackend` consumers) when a marquee-app needs CP-SAT. | Bearer auth | off (FERROX_AUTH_TOKEN unset; tenant header is the gate) | | Tenant allowlist | `quorum-sense` (4 in-flight, compiled into image) | | Health check | `grpc.health.v1.Health` via `tonic-health` | +| Reflection | `grpc.reflection.v1.ServerReflection` via `tonic-reflection` (live since v0.7.2-2966adc) | | Smoke script | `ops/smoke.sh ` | | Cloud Build SHA | `2e9ec147-cc07-42a1-8365-3cd99b6d43a7` (~18 min cold build) | @@ -79,7 +80,7 @@ ready. M4 (`RemoteCpSatBackend` consumers) when a marquee-app needs CP-SAT. - `FerroxSolver/SolveCp` with unknown tenant → `PERMISSION_DENIED: unknown tenant: ` - `FerroxSolver/SolveCp` with `x-converge-app: quorum-sense` + trivial CP (max x, 0≤x≤1) → `status: "optimal"`, `objective_value: 1`, `solver: "cp-sat-v9.15"` -**Known reflection follow-up:** server doesn't expose gRPC reflection, so `grpcurl` needs `-proto` to call. Adding `tonic-reflection` is a follow-up ticket — keeps the API surface discoverable for ops/debugging. +**Reflection follow-up (RESOLVED 2026-06-15):** `tonic-reflection 0.14` registered server-side; image `v0.7.2-2966adc` shipped + smoke verified. `grpcurl :443 list` returns three services (`ferrox.v1.FerroxSolver`, `grpc.health.v1.Health`, `grpc.reflection.v1.ServerReflection`). No `-proto` flag needed. **Smoke connectivity note:** `gcloud run services proxy` failed via Homebrew gcloud (h2c local listener broken). The smoke was run by temporarily flipping `--ingress=all` for ~3 min, hitting the service URL directly with an ID-token-authenticated grpcurl from the dev laptop, then flipping back to `--ingress=internal`. Auth still required throughout. The recurring smoke path will be: Cloud Shell (browser, inside Google's network) once the service is invoker-bound to a Cloud-Shell-reachable identity, OR via the VPC connector from another in-VPC client. From 692bade0459cf0d91275932eeaf4886aa5023cc1 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Thu, 2 Jul 2026 09:29:37 +0200 Subject: [PATCH 25/26] =?UTF-8?q?docs(milestones):=20consolidation=20pass?= =?UTF-8?q?=20=E2=80=94=20assign=20epics,=20archive=20skip=20files?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- kb/Planning/MILESTONES.md | 1 + 1 file changed, 1 insertion(+) diff --git a/kb/Planning/MILESTONES.md b/kb/Planning/MILESTONES.md index 108aed6..35472a8 100644 --- a/kb/Planning/MILESTONES.md +++ b/kb/Planning/MILESTONES.md @@ -29,6 +29,7 @@ source: mixed - [x] Tag v0.5.1 ## Next: Native Solver Assurance Hardening +**Epic:** E9 **Target:** 2026-05/06 | **Tracks:** OR-Tools + HiGHS reproducibility From c9e5c4b5e70e8808d7ab2e4612831964f05d10a6 Mon Sep 17 00:00:00 2001 From: Kenneth Pernyer Date: Thu, 2 Jul 2026 11:08:15 +0200 Subject: [PATCH 26/26] =?UTF-8?q?fix(ci):=20run=20cargo-deny=20in=20runner?= =?UTF-8?q?=20env=20=E2=80=94=20docker=20action=20can't=20see=20toolchain?= =?UTF-8?q?=20or=20siblings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EmbarkStudios/cargo-deny-action@v2 can't install the pinned 1.96.0 musl toolchain and doesn't see sibling checkouts, so cargo metadata dies on converge-core. Mirror the blocking audit job's runner-env pattern. Co-Authored-By: Claude Fable 5 --- .github/workflows/stability.yml | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stability.yml b/.github/workflows/stability.yml index 0983b09..3c09e69 100644 --- a/.github/workflows/stability.yml +++ b/.github/workflows/stability.yml @@ -102,4 +102,16 @@ jobs: - uses: actions/checkout@v6 - name: Checkout Reflective sibling dependencies run: bash scripts/ci/checkout-reflective-siblings.sh - - uses: EmbarkStudios/cargo-deny-action@v2 + # Not EmbarkStudios/cargo-deny-action: its docker container can't + # install the repo's pinned musl toolchain and doesn't see the + # sibling checkouts, so `cargo metadata` fails on converge-core. + # Run cargo-deny in the runner env like the audit job above. + - uses: dtolnay/rust-toolchain@stable + with: + toolchain: ${{ env.RUST_VERSION }} + - uses: Swatinem/rust-cache@v2 + - uses: taiki-e/install-action@v2 + with: + tool: cargo-deny + - name: Run cargo-deny + run: cargo deny check