@@ -128,7 +128,7 @@ pub async fn run_sandbox(
128128 // Load policy and initialize OPA engine
129129 let openshell_endpoint_for_proxy = openshell_endpoint. clone ( ) ;
130130 let sandbox_name_for_agg = sandbox. clone ( ) ;
131- let ( policy, opa_engine, retained_proto) = load_policy (
131+ let ( policy, opa_engine, retained_proto, middleware_install_pending ) = load_policy (
132132 sandbox_id. clone ( ) ,
133133 sandbox,
134134 openshell_endpoint. clone ( ) ,
@@ -390,6 +390,7 @@ pub async fn run_sandbox(
390390 ocsf_enabled : poll_ocsf_enabled,
391391 provider_credentials : poll_provider_credentials,
392392 policy_local_ctx : poll_policy_local,
393+ middleware_install_pending,
393394 } ;
394395
395396 tokio:: spawn ( async move {
@@ -1337,6 +1338,11 @@ async fn load_policy(
13371338 SandboxPolicy ,
13381339 Option < Arc < OpaEngine > > ,
13391340 Option < openshell_core:: proto:: SandboxPolicy > ,
1341+ // True when operator-registered middleware could not be connected at
1342+ // startup and the engine kept the built-in registry. The policy poll loop
1343+ // retries the install so a recovered service is picked up without a config
1344+ // change.
1345+ bool ,
13401346) > {
13411347 // File mode: load OPA engine from rego rules + YAML data (dev override)
13421348 if let ( Some ( policy_file) , Some ( data_file) ) = ( & policy_rules, & policy_data) {
@@ -1366,7 +1372,8 @@ async fn load_policy(
13661372 process : config. process ,
13671373 } ;
13681374 enrich_sandbox_baseline_paths ( & mut policy) ;
1369- return Ok ( ( policy, Some ( Arc :: new ( engine) ) , None ) ) ;
1375+ // File mode has no operator-registered middleware to connect.
1376+ return Ok ( ( policy, Some ( Arc :: new ( engine) ) , None , false ) ) ;
13701377 }
13711378
13721379 // gRPC mode: fetch typed proto policy, construct OPA engine from baked rules + proto data
@@ -1449,16 +1456,45 @@ async fn load_policy(
14491456 // engine is rebuilt with the real PID for symlink resolution.
14501457 info ! ( "Creating OPA engine from proto policy data" ) ;
14511458 let engine = OpaEngine :: from_proto ( & proto_policy) ?;
1452- let middleware_registry =
1459+ // Connect operator-registered middleware services. A connect/describe
1460+ // failure must not abort sandbox startup: unlike the previous hard
1461+ // failure, we degrade to the built-in registry and let each request's
1462+ // `on_error` policy govern matched traffic (deny for fail_closed, allow
1463+ // for fail_open). The policy poll loop retries the install so a
1464+ // recovered service is picked up without a config change. This mirrors
1465+ // the resilient live-reload path.
1466+ let middleware_services = sandbox_config. supervisor_middleware_services . clone ( ) ;
1467+ let middleware_install_pending = match grpc_retry ( "Middleware connect" , || {
14531468 openshell_supervisor_middleware:: MiddlewareRegistry :: connect_services (
1454- sandbox_config . supervisor_middleware_services ,
1469+ middleware_services . clone ( ) ,
14551470 )
1456- . await ?;
1457- engine. replace_middleware_registry ( middleware_registry) ?;
1471+ } )
1472+ . await
1473+ . and_then ( |registry| engine. replace_middleware_registry ( registry) )
1474+ {
1475+ Ok ( ( ) ) => false ,
1476+ Err ( error) => {
1477+ ocsf_emit ! (
1478+ ConfigStateChangeBuilder :: new( ocsf_ctx( ) )
1479+ . severity( SeverityId :: Medium )
1480+ . status( StatusId :: Failure )
1481+ . state( StateId :: Other , "degraded" )
1482+ . unmapped(
1483+ "supervisor_middleware_service_count" ,
1484+ serde_json:: json!( middleware_services. len( ) )
1485+ )
1486+ . message( format!(
1487+ "Supervisor middleware connect failed at startup; continuing with built-in middleware only, per-request on_error governs matched requests [error:{error}]"
1488+ ) )
1489+ . build( )
1490+ ) ;
1491+ true
1492+ }
1493+ } ;
14581494 let opa_engine = Some ( Arc :: new ( engine) ) ;
14591495
14601496 let policy = SandboxPolicy :: try_from ( proto_policy. clone ( ) ) ?;
1461- return Ok ( ( policy, opa_engine, Some ( proto_policy) ) ) ;
1497+ return Ok ( ( policy, opa_engine, Some ( proto_policy) , middleware_install_pending ) ) ;
14621498 }
14631499
14641500 // No policy source available
@@ -1594,6 +1630,10 @@ struct PolicyPollLoopContext {
15941630 ocsf_enabled : Arc < std:: sync:: atomic:: AtomicBool > ,
15951631 provider_credentials : ProviderCredentialState ,
15961632 policy_local_ctx : Option < Arc < openshell_supervisor_network:: policy_local:: PolicyLocalContext > > ,
1633+ /// True when `load_policy` degraded to the built-in middleware registry
1634+ /// because operator services could not be connected at startup. The poll
1635+ /// loop retries the install until it succeeds.
1636+ middleware_install_pending : bool ,
15971637}
15981638
15991639async fn run_policy_poll_loop ( ctx : PolicyPollLoopContext ) -> Result < ( ) > {
@@ -1606,6 +1646,10 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> {
16061646 let mut current_provider_env_revision: u64 = ctx. provider_credentials . snapshot ( ) . revision ;
16071647 let mut current_policy_hash = String :: new ( ) ;
16081648 let mut current_middleware_services = Vec :: new ( ) ;
1649+ // Set when a middleware install is outstanding (degraded at startup or a
1650+ // failed reload). Drives a retry on every poll, independent of the config
1651+ // revision, so a recovered operator service is picked up promptly.
1652+ let mut middleware_sync_pending = ctx. middleware_install_pending ;
16091653 let mut current_settings: std:: collections:: HashMap <
16101654 String ,
16111655 openshell_core:: proto:: EffectiveSetting ,
@@ -1641,14 +1685,70 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> {
16411685 }
16421686 } ;
16431687
1688+ // Reconcile the supervisor middleware registry before evaluating the
1689+ // rest of the config. This runs independently of the config revision so
1690+ // an install that degraded at startup (or failed on an earlier poll) is
1691+ // retried here, letting a recovered operator service be picked up
1692+ // without waiting for a policy change. A failure keeps the
1693+ // last-known-good registry; the request path stays governed by each
1694+ // middleware's `on_error` policy, and a config change is still applied
1695+ // below rather than being blocked by the middleware outage.
1696+ if middleware_sync_pending
1697+ || result. supervisor_middleware_services != current_middleware_services
1698+ {
1699+ match openshell_supervisor_middleware:: MiddlewareRegistry :: connect_services (
1700+ result. supervisor_middleware_services . clone ( ) ,
1701+ )
1702+ . await
1703+ . and_then ( |registry| ctx. opa_engine . replace_middleware_registry ( registry) )
1704+ {
1705+ Ok ( ( ) ) => {
1706+ current_middleware_services = result. supervisor_middleware_services . clone ( ) ;
1707+ middleware_sync_pending = false ;
1708+ ocsf_emit ! (
1709+ ConfigStateChangeBuilder :: new( ocsf_ctx( ) )
1710+ . severity( SeverityId :: Informational )
1711+ . status( StatusId :: Success )
1712+ . state( StateId :: Enabled , "loaded" )
1713+ . unmapped(
1714+ "supervisor_middleware_service_count" ,
1715+ serde_json:: json!( current_middleware_services. len( ) )
1716+ )
1717+ . message( format!(
1718+ "Supervisor middleware registry reloaded [service_count:{}]" ,
1719+ current_middleware_services. len( )
1720+ ) )
1721+ . build( )
1722+ ) ;
1723+ }
1724+ Err ( error) => {
1725+ // Emit only on the transition into the failed state to avoid
1726+ // repeating the same finding on every poll during a
1727+ // sustained outage. The startup degrade path emits its own
1728+ // event.
1729+ if !middleware_sync_pending {
1730+ ocsf_emit ! (
1731+ ConfigStateChangeBuilder :: new( ocsf_ctx( ) )
1732+ . severity( SeverityId :: Medium )
1733+ . status( StatusId :: Failure )
1734+ . state( StateId :: Other , "failed" )
1735+ . message( format!(
1736+ "Supervisor middleware registry reload failed, keeping last-known-good registry [error:{error}]"
1737+ ) )
1738+ . build( )
1739+ ) ;
1740+ }
1741+ middleware_sync_pending = true ;
1742+ }
1743+ }
1744+ }
1745+
16441746 let provider_env_changed = result. provider_env_revision != current_provider_env_revision;
16451747 if result. config_revision == current_config_revision && !provider_env_changed {
16461748 continue ;
16471749 }
16481750
16491751 let policy_changed = result. policy_hash != current_policy_hash;
1650- let middleware_changed =
1651- result. supervisor_middleware_services != current_middleware_services;
16521752
16531753 // Log which settings changed.
16541754 log_setting_changes ( & current_settings, & result. settings ) ;
@@ -1707,47 +1807,6 @@ async fn run_policy_poll_loop(ctx: PolicyPollLoopContext) -> Result<()> {
17071807 }
17081808 }
17091809
1710- if middleware_changed {
1711- match openshell_supervisor_middleware:: MiddlewareRegistry :: connect_services (
1712- result. supervisor_middleware_services . clone ( ) ,
1713- )
1714- . await
1715- . and_then ( |registry| ctx. opa_engine . replace_middleware_registry ( registry) )
1716- {
1717- Ok ( ( ) ) => {
1718- current_middleware_services = result. supervisor_middleware_services . clone ( ) ;
1719- ocsf_emit ! (
1720- ConfigStateChangeBuilder :: new( ocsf_ctx( ) )
1721- . severity( SeverityId :: Informational )
1722- . status( StatusId :: Success )
1723- . state( StateId :: Enabled , "loaded" )
1724- . unmapped(
1725- "supervisor_middleware_service_count" ,
1726- serde_json:: json!( current_middleware_services. len( ) )
1727- )
1728- . message( format!(
1729- "Supervisor middleware registry reloaded [service_count:{}]" ,
1730- current_middleware_services. len( )
1731- ) )
1732- . build( )
1733- ) ;
1734- }
1735- Err ( error) => {
1736- ocsf_emit ! (
1737- ConfigStateChangeBuilder :: new( ocsf_ctx( ) )
1738- . severity( SeverityId :: Medium )
1739- . status( StatusId :: Failure )
1740- . state( StateId :: Other , "failed" )
1741- . message( format!(
1742- "Supervisor middleware registry reload failed, keeping last-known-good registry [error:{error}]"
1743- ) )
1744- . build( )
1745- ) ;
1746- continue ;
1747- }
1748- }
1749- }
1750-
17511810 // Only reload OPA when the policy payload actually changed.
17521811 if policy_changed {
17531812 let Some ( policy) = result. policy . as_ref ( ) else {
0 commit comments